From f08b1b62cd318089730243c8d15eebf3f9573a9b Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 25 Aug 2026 11:25:23 -0400 Subject: [PATCH 01/44] feat: add canvas version history with optimistic concurrency Canvas events (kind 40100) are append-only and have no d-tag, so concurrent edits could silently overwrite each other. Add an optional ["expected-revision", "<64-hex>" | "none"] tag that the relay enforces as an optimistic-concurrency precondition: - Present + matches live head -> accept - "none" + no head yet -> accept (first-creation race guard) - Mismatch -> reject "conflict: canvas changed since it was loaded" - Expected id with no head -> reject "conflict: canvas revision does not exist" - Absent -> today's unconditional append - Malformed / duplicate tag -> reject "invalid:" Check and insert share one per-(community, channel) advisory lock so cross-author edits serialize on the same head; head ordering is created_at DESC, id ASC. SDK build_set_canvas gains an optional expected_revision; CLI adds canvas history, get --revision, and restore (restore re-publishes prior content pinned to the current head, never mutating or deleting). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/channels.rs | 136 +++++++++- crates/buzz-cli/src/lib.rs | 29 ++- crates/buzz-db/src/lib.rs | 2 +- crates/buzz-db/src/store/event.rs | 304 +++++++++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 151 +++++++++++ crates/buzz-sdk/src/builders.rs | 33 ++- 6 files changed, 641 insertions(+), 14 deletions(-) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 72168793588..1b6a94cde45 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -267,16 +267,53 @@ pub async fn cmd_list_channel_members( Ok(()) } -pub async fn cmd_get_canvas(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; +/// Fetch a channel's full canvas event stream (kind 40100). +async fn fetch_canvas_stream( + client: &BuzzClient, + channel_id: &str, +) -> Result, CliError> { let filter = serde_json::json!({ "kinds": [40100], "#h": [channel_id] }); let resp = client.query(&filter).await?; - let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); - if let Some(content) = events - .first() + Ok(serde_json::from_str(&resp).unwrap_or_default()) +} + +/// Return the live canvas head from a stream, applying the relay's head +/// ordering: newest `created_at`, ties broken by lowest event ID. +fn canvas_head(events: &[serde_json::Value]) -> Option<&serde_json::Value> { + events.iter().max_by(|a, b| { + let ts = |e: &serde_json::Value| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + let id = |e: &serde_json::Value| { + e.get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + }; + // Later timestamp wins; on a tie the lower id is the head, so reverse + // the id comparison to make it the max under this ordering. + ts(a).cmp(&ts(b)).then_with(|| id(b).cmp(&id(a))) + }) +} + +pub async fn cmd_get_canvas( + client: &BuzzClient, + channel_id: &str, + revision: Option<&str>, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + if let Some(revision) = revision { + validate_hex64(revision)?; + } + let events = fetch_canvas_stream(client, channel_id).await?; + let selected = match revision { + Some(revision) => events + .iter() + .find(|e| e.get("id").and_then(|v| v.as_str()) == Some(revision)), + None => canvas_head(&events), + }; + if let Some(content) = selected .and_then(|e| e.get("content")) .and_then(|c| c.as_str()) { @@ -287,6 +324,83 @@ pub async fn cmd_get_canvas(client: &BuzzClient, channel_id: &str) -> Result<(), Ok(()) } +/// List canvas revisions newest-first as JSON `{event_id, author, created_at}`. +pub async fn cmd_canvas_history( + client: &BuzzClient, + channel_id: &str, + limit: usize, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + let mut events = fetch_canvas_stream(client, channel_id).await?; + // Newest first; ties broken by lowest id so the head sorts first. + events.sort_by(|a, b| { + let ts = |e: &serde_json::Value| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + let id = |e: &serde_json::Value| { + e.get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + }; + ts(b).cmp(&ts(a)).then_with(|| id(a).cmp(&id(b))) + }); + let revisions: Vec = events + .iter() + .take(limit) + .map(|e| { + serde_json::json!({ + "event_id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "author": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + }) + }) + .collect(); + println!("{}", serde_json::to_string(&revisions).unwrap_or_default()); + Ok(()) +} + +/// Restore the canvas to a previous revision by re-publishing its content with +/// an `expected-revision` precondition pinned to the current head. +pub async fn cmd_restore_canvas( + client: &BuzzClient, + channel_id: &str, + revision: &str, +) -> Result<(), CliError> { + let channel_uuid = parse_uuid(channel_id)?; + validate_hex64(revision)?; + let events = fetch_canvas_stream(client, channel_id).await?; + + let content = events + .iter() + .find(|e| e.get("id").and_then(|v| v.as_str()) == Some(revision)) + .and_then(|e| e.get("content")) + .and_then(|c| c.as_str()) + .ok_or_else(|| { + CliError::Usage(format!( + "revision {revision} not found for channel {channel_id}" + )) + })? + .to_string(); + + let head = canvas_head(&events) + .and_then(|e| e.get("id")) + .and_then(|v| v.as_str()) + .ok_or_else(|| CliError::Other(format!("no canvas head found for channel {channel_id}")))?; + + let builder = buzz_sdk::build_set_canvas(channel_uuid, &content, Some(head)) + .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; + let event = client.sign_event(builder)?; + match client.submit_event(event).await { + Ok(resp) => { + println!("{}", normalize_write_response(&resp)); + Ok(()) + } + Err(CliError::Relay { body, .. }) if body.starts_with("conflict:") => { + Err(CliError::Conflict(body)) + } + Err(e) => Err(e), + } +} + pub async fn cmd_create_channel( client: &BuzzClient, name: &str, @@ -1120,7 +1234,7 @@ pub async fn cmd_create_channel_from_template( .replace("{channel.name}", name) .replace("{template.name}", &template.name); let canvas_result: Result<(), CliError> = async { - let builder = buzz_sdk::build_set_canvas(channel_uuid, &content) + let builder = buzz_sdk::build_set_canvas(channel_uuid, &content, None) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; let event = client.sign_event(builder)?; client.submit_event(event).await?; @@ -1461,7 +1575,7 @@ pub async fn cmd_set_canvas( let content = read_or_stdin(content)?; let channel_uuid = parse_uuid(channel_id)?; - let builder = buzz_sdk::build_set_canvas(channel_uuid, &content) + let builder = buzz_sdk::build_set_canvas(channel_uuid, &content, None) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; let event = client.sign_event(builder)?; @@ -1578,8 +1692,14 @@ pub async fn dispatch( pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Result<(), CliError> { use crate::CanvasCmd; match cmd { - CanvasCmd::Get { channel } => cmd_get_canvas(client, &channel).await, + CanvasCmd::Get { channel, revision } => { + cmd_get_canvas(client, &channel, revision.as_deref()).await + } CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await, + CanvasCmd::History { channel, limit } => cmd_canvas_history(client, &channel, limit).await, + CanvasCmd::Restore { channel, revision } => { + cmd_restore_canvas(client, &channel, &revision).await + } } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 3f2bea73979..03e0ab9195b 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -724,6 +724,10 @@ pub enum CanvasCmd { /// Channel UUID #[arg(long)] channel: String, + /// Fetch a specific historical revision by event ID (64-char hex); + /// defaults to the current head + #[arg(long)] + revision: Option, }, /// Set (replace) the canvas document for a channel Set { @@ -734,6 +738,24 @@ pub enum CanvasCmd { #[arg(long)] content: String, }, + /// List canvas revision history for a channel, newest first + History { + /// Channel UUID + #[arg(long)] + channel: String, + /// Maximum number of revisions to return + #[arg(long, default_value_t = 50)] + limit: usize, + }, + /// Restore the canvas to a previous revision by re-publishing its content + Restore { + /// Channel UUID + #[arg(long)] + channel: String, + /// Revision event ID to restore (64-char hex) + #[arg(long)] + revision: String, + }, } #[derive(Subcommand)] @@ -2360,7 +2382,10 @@ mod tests { "update" ] ); - assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]); + assert_eq!( + names(&cmd, "canvas"), + vec!["get", "history", "restore", "set"] + ); assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]); assert_eq!( names(&cmd, "emoji"), @@ -2463,7 +2488,7 @@ mod tests { fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ ("agents", 5), - ("canvas", 2), + ("canvas", 4), ("channels", 16), ("dms", 4), ("emoji", 5), diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 6f8d0ffb3d4..1372f2a5174 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -61,7 +61,7 @@ pub use community::{ UnarchivedCommunityRecord, }; pub use error::{DbError, Result}; -pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; +pub use event::{ChannelHeadPrecondition, ChannelHeadWriteStatus, EventQuery, DEFAULT_MAX_PAGE_LIMIT}; pub use reaction::ReactionEventInsertOutcome; pub use reminder::DueReminder; pub use usage::UsageMetricsLeader; diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index cb45809eadb..e4b281c43b4 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -1527,6 +1527,132 @@ pub async fn insert_event_with_thread_metadata( Ok(result) } +/// Outcome of a channel-head conditional canvas write. +/// +/// Canvas events (kind 40100) are plain appends that accrue as version history; +/// the "current" canvas is the live head under `created_at DESC, id ASC`. When a +/// write carries an `expected-revision` precondition, this enum reports whether +/// it matched. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChannelHeadWriteStatus { + /// The event was appended as the new channel head. + Inserted, + /// The exact event already existed (idempotent replay of the current head). + Duplicate, + /// `ExpectedHead` was required but no live head exists for the channel/kind. + RevisionMissing, + /// The live head differs from the required `ExpectedHead` (or `ExpectNoHead` + /// was required but a head already exists). + RevisionMismatch, +} + +/// Optimistic-concurrency precondition for [`insert_channel_head_checked`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChannelHeadPrecondition<'a> { + /// Require that no live head exists yet (first creation of the canvas). + ExpectNoHead, + /// Require the live head to match this validated 32-byte event ID. + ExpectedHead(&'a [u8]), +} + +/// Conditionally append a channel-head event (canvas kind 40100) under an +/// optimistic-concurrency precondition. +/// +/// The check and the insert share one advisory lock keyed on +/// `(community, kind, channel)` — deliberately excluding the author, since any +/// channel member edits the same canvas and cross-author concurrent edits must +/// serialize on the same head. The head is read as `created_at DESC, id ASC` +/// (matching the read path), the precondition is evaluated, and on success the +/// event plus its mentions are inserted in the same transaction. Precondition +/// failures mutate nothing. +pub async fn insert_channel_head_checked( + pool: &PgPool, + community_id: CommunityId, + event: &Event, + channel_id: Uuid, + precondition: ChannelHeadPrecondition<'_>, +) -> Result<(StoredEvent, ChannelHeadWriteStatus)> { + let kind_i32 = event_kind_i32(event); + let received_at = Utc::now(); + let incoming_id = event.id.as_bytes(); + + let mut tx = pool.begin().await?; + + // Serialize check+insert per (community, kind, channel). The author is + // intentionally excluded from the key. + let lock_key = crate::replaceable::event_replacement_lock_key( + community_id, + kind_i32, + &[], + Some(channel_id.as_bytes().as_slice()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + + let head: Option> = sqlx::query_scalar( + "SELECT id FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(channel_id) + .fetch_optional(&mut *tx) + .await?; + + // Idempotent replay: the incoming event already is the live head. + if head + .as_ref() + .is_some_and(|id| id.as_slice() == incoming_id.as_slice()) + { + tx.rollback().await?; + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, Some(channel_id), true), + ChannelHeadWriteStatus::Duplicate, + )); + } + + let status = match (precondition, head.as_ref()) { + (ChannelHeadPrecondition::ExpectNoHead, None) => None, + (ChannelHeadPrecondition::ExpectNoHead, Some(_)) => { + Some(ChannelHeadWriteStatus::RevisionMismatch) + } + (ChannelHeadPrecondition::ExpectedHead(_), None) => { + Some(ChannelHeadWriteStatus::RevisionMissing) + } + (ChannelHeadPrecondition::ExpectedHead(expected), Some(id)) => { + if id.as_slice() == expected { + None + } else { + Some(ChannelHeadWriteStatus::RevisionMismatch) + } + } + }; + if let Some(status) = status { + tx.rollback().await?; + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, Some(channel_id), false), + status, + )); + } + + let (stored, was_inserted) = + insert_event_with_thread_metadata_tx(&mut tx, community_id, event, Some(channel_id), None) + .await?; + if !was_inserted { + // Lost an insert race after passing the precondition (another writer + // committed the identical id). Treat as an idempotent duplicate. + tx.rollback().await?; + return Ok((stored, ChannelHeadWriteStatus::Duplicate)); + } + crate::insert_mentions_in_transaction(&mut tx, community_id, event, Some(channel_id)).await?; + tx.commit().await?; + + Ok((stored, ChannelHeadWriteStatus::Inserted)) +} + impl Db { /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. #[datastore_span(name = "insert_event", system = "postgresql")] @@ -2853,4 +2979,182 @@ mod postgres_tests { assert!(!huddle_started_content_links(&wrong_field, channel_id)); assert!(!huddle_started_content_links("not-json", channel_id)); } + + fn make_canvas_event_at(content: &str, created_at: u64) -> nostr::Event { + make_event_at(buzz_core::kind::KIND_CANVAS as u16, content, created_at) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expect_no_head_creates_first_canvas() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# First", 1000); + + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + assert_eq!(status, ChannelHeadWriteStatus::Inserted); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expect_no_head_rejects_when_head_exists() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let first = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &first, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + let second = make_canvas_event_at("# Racing create", 1001); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &second, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("second create attempt"); + assert_eq!(status, ChannelHeadWriteStatus::RevisionMismatch); + + // The losing create must not have been persisted. + let persisted: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(second.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count losing create"); + assert_eq!(persisted, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expected_head_matches_and_advances() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let first = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &first, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + let second = make_canvas_event_at("# Second", 1001); + let head = first.id.as_bytes(); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &second, + channel, + ChannelHeadPrecondition::ExpectedHead(head.as_slice()), + ) + .await + .expect("edit against head"); + assert_eq!(status, ChannelHeadWriteStatus::Inserted); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expected_head_mismatch_rejects() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let first = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &first, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + let stale = make_canvas_event_at("# Stale edit", 1002); + let wrong_head = [0u8; 32]; + let (_, status) = insert_channel_head_checked( + &pool, + community, + &stale, + channel, + ChannelHeadPrecondition::ExpectedHead(&wrong_head), + ) + .await + .expect("stale edit attempt"); + assert_eq!(status, ChannelHeadWriteStatus::RevisionMismatch); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expected_head_missing_rejects() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# Edit with no head", 1000); + let some_head = [1u8; 32]; + + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectedHead(&some_head), + ) + .await + .expect("edit with no head"); + assert_eq!(status, ChannelHeadWriteStatus::RevisionMissing); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_replay_of_head_is_duplicate() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + // Replaying the exact head under ExpectedHead(head) is idempotent. + let head = event.id.as_bytes(); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectedHead(head.as_slice()), + ) + .await + .expect("replay head"); + assert_eq!(status, ChannelHeadWriteStatus::Duplicate); + } } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ee1d0312be9..64a08ebd518 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -191,6 +191,54 @@ fn validate_reaction_emoji(event: &Event, emoji: &str) -> Result<(), IngestError Ok(()) } +/// A validated canvas `expected-revision` precondition. +/// +/// Contract v2: the tag value is either the literal `none` (expect no canvas +/// head yet) or a 64-hex event ID (expect the live head to match it). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CanvasRevisionSpec { + /// Literal `none` — the writer expects no canvas head to exist. + NoHead, + /// A 32-byte event ID the live canvas head must equal. + Head(Vec), +} + +/// Parse the optional canvas `expected-revision` precondition from an event. +/// +/// Returns `Ok(None)` when the tag is absent (backward-compatible unconditional +/// append). A single well-formed tag yields `Some(spec)`. Duplicate +/// `expected-revision` tags or a malformed value reject as `invalid:`. +pub(crate) fn parse_canvas_expected_revision( + event: &Event, +) -> Result, IngestError> { + let mut values = event.tags.iter().filter_map(|tag| { + let parts = tag.as_slice(); + if parts.len() >= 2 && parts[0] == "expected-revision" { + Some(parts[1].as_str()) + } else { + None + } + }); + + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(IngestError::Rejected( + "invalid: duplicate expected-revision tag".into(), + )); + } + + if value == "none" { + return Ok(Some(CanvasRevisionSpec::NoHead)); + } + let bytes = hex::decode(value) + .ok() + .filter(|bytes| bytes.len() == 32) + .ok_or_else(|| IngestError::Rejected("invalid: bad expected canvas revision".into()))?; + Ok(Some(CanvasRevisionSpec::Head(bytes))) +} + /// How the HTTP caller authenticated (for [`IngestAuth::Http`]). #[derive(Debug, Clone)] pub enum HttpAuthMethod { @@ -3144,6 +3192,15 @@ async fn ingest_event_inner( }); } + // Parse a canvas `expected-revision` precondition once, ahead of the write + // dispatch. Malformed or duplicate tags reject here (never reaching the DB); + // an absent tag yields `None`, routing canvas writes to the generic append. + let canvas_revision_spec = if kind_u32 == KIND_CANVAS { + parse_canvas_expected_revision(&event)? + } else { + None + }; + let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. @@ -3167,6 +3224,37 @@ async fn ingest_event_inner( .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) .await .map_err(|e| IngestError::Internal(format!("error: {e}")))? + } else if let Some(spec) = canvas_revision_spec.as_ref() { + // Canvas write carrying an optimistic-concurrency precondition. Plain + // canvas writes (no `expected-revision` tag) fall through to the generic + // append path below, preserving today's unconditional behavior. The + // channel is guaranteed present here: KIND_CANVAS requires an `h` tag and + // step 5b resolved it into `channel_id`. + let channel = channel_id + .ok_or_else(|| IngestError::Rejected("invalid: canvas event missing channel".into()))?; + let precondition = match spec { + CanvasRevisionSpec::NoHead => buzz_db::ChannelHeadPrecondition::ExpectNoHead, + CanvasRevisionSpec::Head(id) => buzz_db::ChannelHeadPrecondition::ExpectedHead(id), + }; + let (stored_event, status) = state + .db + .insert_channel_head_checked(tenant.community(), &event, channel, precondition) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + match status { + buzz_db::ChannelHeadWriteStatus::RevisionMissing => { + return Err(IngestError::Rejected( + "conflict: canvas revision does not exist".into(), + )); + } + buzz_db::ChannelHeadWriteStatus::RevisionMismatch => { + return Err(IngestError::Rejected( + "conflict: canvas changed since it was loaded".into(), + )); + } + buzz_db::ChannelHeadWriteStatus::Inserted => (stored_event, true), + buzz_db::ChannelHeadWriteStatus::Duplicate => (stored_event, false), + } } else { let thread_params = thread_meta.as_ref().map(|m| m.as_params()); match state @@ -3356,6 +3444,69 @@ mod postgres_tests { ); } + #[test] + fn canvas_expected_revision_absent_is_none() { + let event = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "content") + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign canvas event"); + assert_eq!(parse_canvas_expected_revision(&event).unwrap(), None); + } + + #[test] + fn canvas_expected_revision_none_sentinel() { + let event = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "content") + .tags([nostr::Tag::parse(["expected-revision", "none"]).unwrap()]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign canvas event"); + assert_eq!( + parse_canvas_expected_revision(&event).unwrap(), + Some(CanvasRevisionSpec::NoHead) + ); + } + + #[test] + fn canvas_expected_revision_valid_hex() { + let id = "a".repeat(64); + let event = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "content") + .tags([nostr::Tag::parse(["expected-revision", &id]).unwrap()]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign canvas event"); + assert_eq!( + parse_canvas_expected_revision(&event).unwrap(), + Some(CanvasRevisionSpec::Head(hex::decode(&id).unwrap())) + ); + } + + #[test] + fn canvas_expected_revision_rejects_malformed_hex() { + for bad in ["zz", &"a".repeat(63), &"a".repeat(66)] { + let event = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "content") + .tags([nostr::Tag::parse(["expected-revision", bad]).unwrap()]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign canvas event"); + assert!(matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(m)) if m.contains("bad expected canvas revision") + )); + } + } + + #[test] + fn canvas_expected_revision_rejects_duplicate_tags() { + let id = "a".repeat(64); + let event = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "content") + .tags([ + nostr::Tag::parse(["expected-revision", &id]).unwrap(), + nostr::Tag::parse(["expected-revision", "none"]).unwrap(), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign canvas event"); + assert!(matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(m)) if m.contains("duplicate expected-revision") + )); + } + #[test] fn reaction_validation_accepts_wrapped_max_shortcode() { let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f43887b65b1..604de22cdde 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -555,8 +555,20 @@ pub fn build_custom_emoji_set(emojis: &[CustomEmoji]) -> Result Result { - let tags = vec![tag(&["h", &channel_id.to_string()])?]; +/// +/// When `expected_revision` is set, the relay applies it as an optimistic +/// concurrency precondition on the channel's live canvas head: a 64-hex event +/// ID requires the head to match, and the literal `none` requires no head to +/// exist yet. Omit it for an unconditional append (backward compatible). +pub fn build_set_canvas( + channel_id: Uuid, + content: &str, + expected_revision: Option<&str>, +) -> Result { + let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; + if let Some(expected_revision) = expected_revision { + tags.push(tag(&["expected-revision", expected_revision])?); + } Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags)) } @@ -2991,10 +3003,25 @@ mod tests { #[test] fn set_canvas_happy_path() { let cid = uuid(); - let ev = sign(build_set_canvas(cid, "# Canvas\nHello").unwrap()); + let ev = sign(build_set_canvas(cid, "# Canvas\nHello", None).unwrap()); assert_eq!(ev.kind.as_u16(), 40100); assert!(has_tag(&ev, "h", &cid.to_string())); assert_eq!(ev.content, "# Canvas\nHello"); + assert!(!ev.tags.iter().any(|t| t + .as_slice() + .first() + .is_some_and(|k| k == "expected-revision"))); + } + + #[test] + fn set_canvas_pins_expected_revision() { + let cid = uuid(); + let head = event_id().to_hex(); + let ev = sign(build_set_canvas(cid, "# Canvas\nHi", Some(&head)).unwrap()); + assert!(has_tag(&ev, "expected-revision", &head)); + + let create = sign(build_set_canvas(cid, "# New", Some("none")).unwrap()); + assert!(has_tag(&create, "expected-revision", "none")); } #[test] From 27187bbaedc98a43d24909310dfce998676ac278 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 25 Aug 2026 12:09:56 -0400 Subject: [PATCH 02/44] fix(canvas): enforce head advancement, tighten malformed tags, unclamp CLI Contract v3 amendments to the canvas expected-revision precondition: - Head-advancement guarantee: a matching ExpectedHead precondition now also requires the candidate to sort strictly ahead of the head under (created_at DESC, id ASC); otherwise reject SupersedeFailed (conflict: canvas write does not supersede the current head). Prevents a same-second lower-id or behind-clock writer from 'succeeding' without advancing the visible head. - CLI restore applies writer discipline: created_at = max(now, head+1). - parse_canvas_expected_revision rejects one-element and 3+-element tags as invalid: instead of treating them as absent; still at most one tag. - CLI history uses query_paginated (was one-shot, clamped to 1000); get --revision and restore fetch by ID-scoped query; restore reads head via a separate limit:1 query. No silent truncation. - Idempotent replay codified: byte-identical resubmission short-circuits to Duplicate before precondition evaluation. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/channels.rs | 122 +++++---- crates/buzz-db/src/store/event.rs | 316 ++++++++++++++++++++++- crates/buzz-relay/src/handlers/ingest.rs | 61 ++++- 3 files changed, 431 insertions(+), 68 deletions(-) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 1b6a94cde45..f79f65bdde6 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -267,34 +267,43 @@ pub async fn cmd_list_channel_members( Ok(()) } -/// Fetch a channel's full canvas event stream (kind 40100). -async fn fetch_canvas_stream( +/// Fetch the live canvas head (kind 40100) for a channel. +/// +/// The relay orders results `created_at DESC, id ASC`, so a `limit: 1` query +/// returns exactly the head every surface agrees on — no full-stream scan, no +/// silent page clamp. +async fn fetch_canvas_head( client: &BuzzClient, channel_id: &str, -) -> Result, CliError> { +) -> Result, CliError> { let filter = serde_json::json!({ "kinds": [40100], - "#h": [channel_id] + "#h": [channel_id], + "limit": 1, }); let resp = client.query(&filter).await?; - Ok(serde_json::from_str(&resp).unwrap_or_default()) + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + Ok(events.into_iter().next()) } -/// Return the live canvas head from a stream, applying the relay's head -/// ordering: newest `created_at`, ties broken by lowest event ID. -fn canvas_head(events: &[serde_json::Value]) -> Option<&serde_json::Value> { - events.iter().max_by(|a, b| { - let ts = |e: &serde_json::Value| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); - let id = |e: &serde_json::Value| { - e.get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string() - }; - // Later timestamp wins; on a tie the lower id is the head, so reverse - // the id comparison to make it the max under this ordering. - ts(a).cmp(&ts(b)).then_with(|| id(b).cmp(&id(a))) - }) +/// Fetch a single canvas revision by event ID, scoped to the channel and kind. +/// +/// An ID-scoped query resolves revisions of any age; scanning a capped stream +/// would report a retained-but-old revision as absent. +async fn fetch_canvas_revision( + client: &BuzzClient, + channel_id: &str, + revision: &str, +) -> Result, CliError> { + let filter = serde_json::json!({ + "ids": [revision], + "kinds": [40100], + "#h": [channel_id], + "limit": 1, + }); + let resp = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + Ok(events.into_iter().next()) } pub async fn cmd_get_canvas( @@ -303,17 +312,15 @@ pub async fn cmd_get_canvas( revision: Option<&str>, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - if let Some(revision) = revision { - validate_hex64(revision)?; - } - let events = fetch_canvas_stream(client, channel_id).await?; let selected = match revision { - Some(revision) => events - .iter() - .find(|e| e.get("id").and_then(|v| v.as_str()) == Some(revision)), - None => canvas_head(&events), + Some(revision) => { + validate_hex64(revision)?; + fetch_canvas_revision(client, channel_id, revision).await? + } + None => fetch_canvas_head(client, channel_id).await?, }; if let Some(content) = selected + .as_ref() .and_then(|e| e.get("content")) .and_then(|c| c.as_str()) { @@ -325,27 +332,23 @@ pub async fn cmd_get_canvas( } /// List canvas revisions newest-first as JSON `{event_id, author, created_at}`. +/// +/// Uses composite `(until, before_id)` pagination so a `limit` above one relay +/// page returns the full requested window instead of a silently truncated one. pub async fn cmd_canvas_history( client: &BuzzClient, channel_id: &str, limit: usize, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - let mut events = fetch_canvas_stream(client, channel_id).await?; - // Newest first; ties broken by lowest id so the head sorts first. - events.sort_by(|a, b| { - let ts = |e: &serde_json::Value| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); - let id = |e: &serde_json::Value| { - e.get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string() - }; - ts(b).cmp(&ts(a)).then_with(|| id(a).cmp(&id(b))) + let filter = serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], }); + let capped = u32::try_from(limit).unwrap_or(u32::MAX); + let events = client.query_paginated(filter, capped).await?; let revisions: Vec = events .iter() - .take(limit) .map(|e| { serde_json::json!({ "event_id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), @@ -360,18 +363,25 @@ pub async fn cmd_canvas_history( /// Restore the canvas to a previous revision by re-publishing its content with /// an `expected-revision` precondition pinned to the current head. +/// +/// The target revision is fetched by an ID-scoped query (resolves any age) and +/// the head by a separate `limit: 1` query — neither scans the full stream. The +/// republished event is signed with `created_at = max(now, head.created_at + 1)` +/// (contract v3 writer discipline) so it sorts strictly ahead of the asserted +/// head and the relay's head-advancement guard is not tripped. pub async fn cmd_restore_canvas( client: &BuzzClient, channel_id: &str, revision: &str, ) -> Result<(), CliError> { + use nostr::Timestamp; + let channel_uuid = parse_uuid(channel_id)?; validate_hex64(revision)?; - let events = fetch_canvas_stream(client, channel_id).await?; - let content = events - .iter() - .find(|e| e.get("id").and_then(|v| v.as_str()) == Some(revision)) + let content = fetch_canvas_revision(client, channel_id, revision) + .await? + .as_ref() .and_then(|e| e.get("content")) .and_then(|c| c.as_str()) .ok_or_else(|| { @@ -381,13 +391,18 @@ pub async fn cmd_restore_canvas( })? .to_string(); - let head = canvas_head(&events) - .and_then(|e| e.get("id")) + let head = fetch_canvas_head(client, channel_id) + .await? + .ok_or_else(|| CliError::Other(format!("no canvas head found for channel {channel_id}")))?; + let head_id = head + .get("id") .and_then(|v| v.as_str()) .ok_or_else(|| CliError::Other(format!("no canvas head found for channel {channel_id}")))?; + let head_created_at = head.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); - let builder = buzz_sdk::build_set_canvas(channel_uuid, &content, Some(head)) - .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; + let builder = buzz_sdk::build_set_canvas(channel_uuid, &content, Some(head_id)) + .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))? + .custom_created_at(Timestamp::from(canvas_write_timestamp(head_created_at))); let event = client.sign_event(builder)?; match client.submit_event(event).await { Ok(resp) => { @@ -401,6 +416,17 @@ pub async fn cmd_restore_canvas( } } +/// Writer-discipline timestamp for a canvas write asserting `head_created_at`: +/// `max(now, head.created_at + 1)`. Guarantees the new event sorts strictly +/// ahead of the asserted head under `created_at DESC, id ASC` (contract v3). +fn canvas_write_timestamp(head_created_at: u64) -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + now.max(head_created_at.saturating_add(1)) +} + pub async fn cmd_create_channel( client: &BuzzClient, name: &str, diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index e4b281c43b4..4f101ca594b 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -1544,6 +1544,11 @@ pub enum ChannelHeadWriteStatus { /// The live head differs from the required `ExpectedHead` (or `ExpectNoHead` /// was required but a head already exists). RevisionMismatch, + /// The precondition matched, but the candidate does not sort strictly ahead + /// of the current head under `created_at DESC, id ASC`, so accepting it + /// would leave the visible canvas unchanged. Rejected to preserve the + /// invariant that an accepted tagged write IS the new head. + SupersedeFailed, } /// Optimistic-concurrency precondition for [`insert_channel_head_checked`]. @@ -1555,6 +1560,28 @@ pub enum ChannelHeadPrecondition<'a> { ExpectedHead(&'a [u8]), } +/// Whether a candidate channel-head event sorts strictly ahead of the current +/// head under the canonical `created_at DESC, id ASC` ordering — i.e. whether +/// accepting it actually makes it the new head. +/// +/// The head is the row with the greatest `created_at`, ties broken by the +/// lowest `id`. So the candidate wins iff it has a later `created_at`, or an +/// equal `created_at` with a strictly lower `id`. +fn candidate_supersedes_head( + candidate: &Event, + candidate_id: &[u8; 32], + head_created_at: DateTime, + head_id: &[u8], +) -> bool { + let candidate_secs = candidate.created_at.as_secs() as i64; + let head_secs = head_created_at.timestamp(); + match candidate_secs.cmp(&head_secs) { + std::cmp::Ordering::Greater => true, + std::cmp::Ordering::Less => false, + std::cmp::Ordering::Equal => candidate_id.as_slice() < head_id, + } +} + /// Conditionally append a channel-head event (canvas kind 40100) under an /// optimistic-concurrency precondition. /// @@ -1565,6 +1592,25 @@ pub enum ChannelHeadPrecondition<'a> { /// (matching the read path), the precondition is evaluated, and on success the /// event plus its mentions are inserted in the same transaction. Precondition /// failures mutate nothing. +/// +/// # Head-advancement guarantee (contract v3) +/// +/// A matching `ExpectedHead` precondition additionally requires the candidate +/// to sort strictly ahead of the current head under `created_at DESC, id ASC`. +/// Otherwise the write would be accepted and fanned out yet leave the visible +/// head unchanged — a same-second lower-id or behind-clock writer would +/// "succeed" without restoring the selected content or advancing the canvas. +/// Such writes reject as [`ChannelHeadWriteStatus::SupersedeFailed`]. First-party +/// writers sign `created_at = max(now, head.created_at + 1)`, so this reject is +/// practically unreachable outside clock pathology. +/// +/// # Idempotent replay exception (contract v3) +/// +/// Re-submitting the byte-identical event that already is the live head returns +/// [`ChannelHeadWriteStatus::Duplicate`] without evaluating the supplied +/// precondition. A duplicate insert cannot change state, so evaluating its +/// precondition buys nothing and would turn a safe transport retry (identical +/// bytes replayed after a lost response) into a false conflict. pub async fn insert_channel_head_checked( pool: &PgPool, community_id: CommunityId, @@ -1591,8 +1637,8 @@ pub async fn insert_channel_head_checked( .execute(&mut *tx) .await?; - let head: Option> = sqlx::query_scalar( - "SELECT id FROM events \ + let head: Option<(Vec, DateTime)> = sqlx::query_as( + "SELECT id, created_at FROM events \ WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \ ORDER BY created_at DESC, id ASC LIMIT 1", ) @@ -1602,10 +1648,13 @@ pub async fn insert_channel_head_checked( .fetch_optional(&mut *tx) .await?; - // Idempotent replay: the incoming event already is the live head. + // Idempotent replay: the incoming event already is the live head. Returns + // before precondition evaluation — a duplicate insert cannot change state, + // so a byte-identical transport retry must never surface as a false + // conflict (contract v3). if head .as_ref() - .is_some_and(|id| id.as_slice() == incoming_id.as_slice()) + .is_some_and(|(id, _)| id.as_slice() == incoming_id.as_slice()) { tx.rollback().await?; return Ok(( @@ -1622,11 +1671,16 @@ pub async fn insert_channel_head_checked( (ChannelHeadPrecondition::ExpectedHead(_), None) => { Some(ChannelHeadWriteStatus::RevisionMissing) } - (ChannelHeadPrecondition::ExpectedHead(expected), Some(id)) => { - if id.as_slice() == expected { - None - } else { + (ChannelHeadPrecondition::ExpectedHead(expected), Some((id, head_created_at))) => { + if id.as_slice() != expected { Some(ChannelHeadWriteStatus::RevisionMismatch) + } else if !candidate_supersedes_head(event, incoming_id, *head_created_at, id) { + // Precondition matched, but the candidate cannot become the + // head under `created_at DESC, id ASC`. Accepting it would leave + // the visible canvas unchanged (contract v3 head-advancement). + Some(ChannelHeadWriteStatus::SupersedeFailed) + } else { + None } } }; @@ -3157,4 +3211,250 @@ mod postgres_tests { .expect("replay head"); assert_eq!(status, ChannelHeadWriteStatus::Duplicate); } + + /// Contract v3 idempotent-replay exception: replaying the byte-identical + /// head succeeds as a no-op even when the supplied precondition would + /// otherwise conflict (a stale `ExpectNoHead` here). Precondition is not + /// evaluated for a duplicate, so safe transport retry never becomes a false + /// conflict. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_replay_skips_stale_precondition() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + // Replay the same bytes with a now-stale `ExpectNoHead` tag: a head + // exists, so the precondition would reject — but replay short-circuits. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("replay with stale none tag"); + assert_eq!(status, ChannelHeadWriteStatus::Duplicate); + } + + /// Contract v3 head-advancement guarantee: a candidate whose `created_at` + /// equals the head's but whose id is HIGHER cannot become the head under + /// `created_at DESC, id ASC`, so it rejects even though the precondition + /// matches. Accepting it would leave the visible canvas unchanged. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_rejects_same_second_higher_id() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + // Two events at the same second; order them by id. + let (lower, higher) = same_second_ordered_pair(1000); + insert_channel_head_checked( + &pool, + community, + &lower, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas is lower-id head"); + + // Candidate has the same created_at but a higher id → cannot supersede. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &higher, + channel, + ChannelHeadPrecondition::ExpectedHead(lower.id.as_bytes().as_slice()), + ) + .await + .expect("same-second higher-id edit"); + assert_eq!(status, ChannelHeadWriteStatus::SupersedeFailed); + + // The rejected write must not have been persisted. + let persisted: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(higher.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rejected write"); + assert_eq!(persisted, 0); + } + + /// Contract v3 head-advancement guarantee: a candidate at the same second + /// with a LOWER id does sort strictly ahead of the head, so it advances. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_accepts_same_second_lower_id() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + let (lower, higher) = same_second_ordered_pair(1000); + // Seed the higher-id event as the head first. + insert_channel_head_checked( + &pool, + community, + &higher, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas is higher-id head"); + + // Lower id at the same second sorts strictly ahead → advances. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &lower, + channel, + ChannelHeadPrecondition::ExpectedHead(higher.id.as_bytes().as_slice()), + ) + .await + .expect("same-second lower-id edit"); + assert_eq!(status, ChannelHeadWriteStatus::Inserted); + } + + /// Contract v3 head-advancement guarantee: a behind-clock writer whose + /// `created_at` predates the head cannot supersede it, even with a matching + /// precondition. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_rejects_behind_clock_writer() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let head = make_canvas_event_at("# Head", 2000); + insert_channel_head_checked( + &pool, + community, + &head, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("head at t=2000"); + + // Writer's clock is 15 minutes behind — created_at earlier than head. + let behind = make_canvas_event_at("# Behind clock", 1100); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &behind, + channel, + ChannelHeadPrecondition::ExpectedHead(head.id.as_bytes().as_slice()), + ) + .await + .expect("behind-clock edit"); + assert_eq!(status, ChannelHeadWriteStatus::SupersedeFailed); + } + + /// Build two canvas events sharing `created_at`, returned `(lower, higher)` + /// by event id. Regenerates until the ids differ (always, since keys differ) + /// so tests can assert deterministic head ordering on the id tiebreak. + fn same_second_ordered_pair(created_at: u64) -> (nostr::Event, nostr::Event) { + let a = make_canvas_event_at("# A", created_at); + let b = make_canvas_event_at("# B", created_at); + if a.id.as_bytes() <= b.id.as_bytes() { + (a, b) + } else { + (b, a) + } + } + + /// Contract v3 race soundness: two concurrent authors both assert the same + /// live head and both sign strictly-advancing writes. The per-(community, + /// channel) advisory lock must serialize check+insert so exactly one wins + /// (`Inserted`) and the other observes the moved head (`RevisionMismatch`). + /// Exactly one write may become the visible head. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_concurrent_authors_only_one_advances() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + let base = make_canvas_event_at("# Base", 1000); + insert_channel_head_checked( + &pool, + community, + &base, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("seed base head"); + let base_id = base.id.to_bytes().to_vec(); + + // Both edits assert `base` as their head and are timestamped strictly + // ahead of it (writer discipline), so neither trips SupersedeFailed — + // only the advisory-lock serialization decides the winner. + let a = make_canvas_event_at("# Author A", 1001); + let b = make_canvas_event_at("# Author B", 1002); + + let (pool_a, pool_b) = (pool.clone(), pool.clone()); + let (id_a, id_b) = (base_id.clone(), base_id.clone()); + let ta = tokio::spawn(async move { + insert_channel_head_checked( + &pool_a, + community, + &a, + channel, + ChannelHeadPrecondition::ExpectedHead(&id_a), + ) + .await + .map(|(_, status)| status) + }); + let tb = tokio::spawn(async move { + insert_channel_head_checked( + &pool_b, + community, + &b, + channel, + ChannelHeadPrecondition::ExpectedHead(&id_b), + ) + .await + .map(|(_, status)| status) + }); + + let status_a = ta.await.expect("join A").expect("insert A"); + let status_b = tb.await.expect("join B").expect("insert B"); + + let mut statuses = [status_a, status_b]; + statuses.sort_by_key(|s| format!("{s:?}")); + assert_eq!( + statuses, + [ + ChannelHeadWriteStatus::Inserted, + ChannelHeadWriteStatus::RevisionMismatch + ], + "exactly one concurrent write advances the head; got {statuses:?}" + ); + + // Exactly one new canvas row (beyond the base) was committed. + let head_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("count committed canvas rows"); + assert_eq!(head_count, 2, "base plus exactly one winning edit"); + } } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 64a08ebd518..5eb64d10ee7 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -206,28 +206,34 @@ pub(crate) enum CanvasRevisionSpec { /// Parse the optional canvas `expected-revision` precondition from an event. /// /// Returns `Ok(None)` when the tag is absent (backward-compatible unconditional -/// append). A single well-formed tag yields `Some(spec)`. Duplicate -/// `expected-revision` tags or a malformed value reject as `invalid:`. +/// append). The tag shape is exactly `["expected-revision", value]`: a +/// one-element `["expected-revision"]` or any three-or-more-element form is +/// malformed and rejects `invalid:` — it is never treated as absent. At most +/// one `expected-revision` tag may be present. A single well-formed tag yields +/// `Some(spec)`. pub(crate) fn parse_canvas_expected_revision( event: &Event, ) -> Result, IngestError> { - let mut values = event.tags.iter().filter_map(|tag| { - let parts = tag.as_slice(); - if parts.len() >= 2 && parts[0] == "expected-revision" { - Some(parts[1].as_str()) - } else { - None - } - }); + let mut tags = event + .tags + .iter() + .map(nostr::Tag::as_slice) + .filter(|parts| parts.first().map(String::as_str) == Some("expected-revision")); - let Some(value) = values.next() else { + let Some(tag) = tags.next() else { return Ok(None); }; - if values.next().is_some() { + if tags.next().is_some() { return Err(IngestError::Rejected( "invalid: duplicate expected-revision tag".into(), )); } + if tag.len() != 2 { + return Err(IngestError::Rejected( + "invalid: expected-revision tag must have exactly one value".into(), + )); + } + let value = tag[1].as_str(); if value == "none" { return Ok(Some(CanvasRevisionSpec::NoHead)); @@ -3252,6 +3258,11 @@ async fn ingest_event_inner( "conflict: canvas changed since it was loaded".into(), )); } + buzz_db::ChannelHeadWriteStatus::SupersedeFailed => { + return Err(IngestError::Rejected( + "conflict: canvas write does not supersede the current head".into(), + )); + } buzz_db::ChannelHeadWriteStatus::Inserted => (stored_event, true), buzz_db::ChannelHeadWriteStatus::Duplicate => (stored_event, false), } @@ -3491,6 +3502,32 @@ mod postgres_tests { } } + #[test] + fn canvas_expected_revision_rejects_one_element_tag() { + // A bare `["expected-revision"]` is malformed, never treated as absent. + let event = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "content") + .tags([nostr::Tag::parse(["expected-revision"]).unwrap()]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign canvas event"); + assert!(matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(m)) if m.contains("exactly one value") + )); + } + + #[test] + fn canvas_expected_revision_rejects_three_element_tag() { + let id = "a".repeat(64); + let event = EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "content") + .tags([nostr::Tag::parse(["expected-revision", &id, "extra"]).unwrap()]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign canvas event"); + assert!(matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(m)) if m.contains("exactly one value") + )); + } + #[test] fn canvas_expected_revision_rejects_duplicate_tags() { let id = "a".repeat(64); From 0c529b057c32daf305b69f2ea2e0c8d1c55e44ef Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 25 Aug 2026 12:21:00 -0400 Subject: [PATCH 03/44] fix(canvas): bound history limit and centralize v3 writer discipline in SDK Pass-3 review fixes: - canvas history --limit is now a u32 bounded 1..=10000 at parse time (Clap range parser) instead of an unbounded usize silently clamped to u32::MAX; the max stays above one 1000-row relay page so the pagination path remains reachable. Boundary tests for 0, max, max+1. - Add buzz_sdk::build_set_canvas_after_head, the single first-party implementation of contract-v3 writer discipline (expected-revision tag + created_at = max(now, head.created_at + 1)); CLI restore routes through it instead of re-deriving the timestamp locally. - Correct the head-advancement doc: a same-second HIGHER-id write is the one that cannot supersede the head. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/channels.rs | 32 +++++--------- crates/buzz-cli/src/lib.rs | 29 ++++++++++-- crates/buzz-db/src/store/event.rs | 2 +- crates/buzz-sdk/src/builders.rs | 56 +++++++++++++++++++++++- 4 files changed, 92 insertions(+), 27 deletions(-) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index f79f65bdde6..3a33bd81cdb 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -335,18 +335,18 @@ pub async fn cmd_get_canvas( /// /// Uses composite `(until, before_id)` pagination so a `limit` above one relay /// page returns the full requested window instead of a silently truncated one. +/// The Clap layer bounds `limit` to 1–10000, so the request is never unbounded. pub async fn cmd_canvas_history( client: &BuzzClient, channel_id: &str, - limit: usize, + limit: u32, ) -> Result<(), CliError> { validate_uuid(channel_id)?; let filter = serde_json::json!({ "kinds": [40100], "#h": [channel_id], }); - let capped = u32::try_from(limit).unwrap_or(u32::MAX); - let events = client.query_paginated(filter, capped).await?; + let events = client.query_paginated(filter, limit).await?; let revisions: Vec = events .iter() .map(|e| { @@ -366,16 +366,15 @@ pub async fn cmd_canvas_history( /// /// The target revision is fetched by an ID-scoped query (resolves any age) and /// the head by a separate `limit: 1` query — neither scans the full stream. The -/// republished event is signed with `created_at = max(now, head.created_at + 1)` -/// (contract v3 writer discipline) so it sorts strictly ahead of the asserted -/// head and the relay's head-advancement guard is not tripped. +/// republished event is built via [`buzz_sdk::build_set_canvas_after_head`], +/// which applies contract-v3 writer discipline (`created_at = max(now, +/// head.created_at + 1)`) so it sorts strictly ahead of the asserted head and +/// the relay's head-advancement guard is not tripped. pub async fn cmd_restore_canvas( client: &BuzzClient, channel_id: &str, revision: &str, ) -> Result<(), CliError> { - use nostr::Timestamp; - let channel_uuid = parse_uuid(channel_id)?; validate_hex64(revision)?; @@ -400,9 +399,9 @@ pub async fn cmd_restore_canvas( .ok_or_else(|| CliError::Other(format!("no canvas head found for channel {channel_id}")))?; let head_created_at = head.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); - let builder = buzz_sdk::build_set_canvas(channel_uuid, &content, Some(head_id)) - .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))? - .custom_created_at(Timestamp::from(canvas_write_timestamp(head_created_at))); + let builder = + buzz_sdk::build_set_canvas_after_head(channel_uuid, &content, head_id, head_created_at) + .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; let event = client.sign_event(builder)?; match client.submit_event(event).await { Ok(resp) => { @@ -416,17 +415,6 @@ pub async fn cmd_restore_canvas( } } -/// Writer-discipline timestamp for a canvas write asserting `head_created_at`: -/// `max(now, head.created_at + 1)`. Guarantees the new event sorts strictly -/// ahead of the asserted head under `created_at DESC, id ASC` (contract v3). -fn canvas_write_timestamp(head_created_at: u64) -> u64 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - now.max(head_created_at.saturating_add(1)) -} - pub async fn cmd_create_channel( client: &BuzzClient, name: &str, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 03e0ab9195b..285a8e13f6f 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -743,9 +743,9 @@ pub enum CanvasCmd { /// Channel UUID #[arg(long)] channel: String, - /// Maximum number of revisions to return - #[arg(long, default_value_t = 50)] - limit: usize, + /// Maximum number of revisions to return (1–10000) + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=10_000))] + limit: u32, }, /// Restore the canvas to a previous revision by re-publishing its content Restore { @@ -2247,6 +2247,29 @@ mod tests { .is_err()); } + /// `canvas history --limit` is bounded 1–10000 at parse time: zero and + /// max+1 reject, the maximum is accepted, and the max stays above one + /// 1,000-row relay page so the >1,000 pagination path remains reachable. + #[test] + fn canvas_history_limit_is_bounded() { + let channel = "123e4567-e89b-12d3-a456-426614174000"; + let parse = |limit: &str| { + Cli::try_parse_from([ + "buzz", + "canvas", + "history", + "--channel", + channel, + "--limit", + limit, + ]) + }; + assert!(parse("0").is_err(), "zero must reject"); + assert!(parse("10001").is_err(), "max+1 must reject"); + assert!(parse("10000").is_ok(), "maximum must be accepted"); + assert!(parse("1000").is_ok(), "one relay page must be reachable"); + } + #[test] fn set_status_clear_rejects_text_and_emoji() { for extra in [["--text", "busy"], ["--emoji", "🎶"]] { diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 4f101ca594b..8e4d4617ff6 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -1598,7 +1598,7 @@ fn candidate_supersedes_head( /// A matching `ExpectedHead` precondition additionally requires the candidate /// to sort strictly ahead of the current head under `created_at DESC, id ASC`. /// Otherwise the write would be accepted and fanned out yet leave the visible -/// head unchanged — a same-second lower-id or behind-clock writer would +/// head unchanged — a same-second higher-id or behind-clock writer would /// "succeed" without restoring the selected content or advancing the canvas. /// Such writes reject as [`ChannelHeadWriteStatus::SupersedeFailed`]. First-party /// writers sign `created_at = max(now, head.created_at + 1)`, so this reject is diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 604de22cdde..47bffb70783 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1,4 +1,4 @@ -//! Typed event builder functions (38 builders). +//! Typed event builder functions (39 builders). //! //! All functions return `Result`. //! The caller signs: `builder.sign_with_keys(&keys)?`. @@ -572,6 +572,38 @@ pub fn build_set_canvas( Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags)) } +/// Build a canvas write (kind 40100) that edits or restores against a known +/// head, applying contract-v3 writer discipline in one place. +/// +/// Sets the `expected-revision` precondition to `head_id` and stamps +/// `created_at = max(now, head_created_at + 1)` so the event sorts strictly +/// ahead of the head it asserts under `created_at DESC, id ASC`. This is what +/// keeps the relay's head-advancement guard +/// (`conflict: canvas write does not supersede the current head`) unreachable +/// for a legitimate first-party write whose local clock lags the head. First-party +/// signers (CLI restore, Desktop save/restore) MUST route disciplined canvas +/// writes through this helper rather than re-deriving the timestamp. +pub fn build_set_canvas_after_head( + channel_id: Uuid, + content: &str, + head_id: &str, + head_created_at: u64, +) -> Result { + let created_at = canvas_write_created_at(head_created_at); + Ok(build_set_canvas(channel_id, content, Some(head_id))? + .custom_created_at(nostr::Timestamp::from(created_at))) +} + +/// Contract-v3 writer-discipline timestamp for a canvas write asserting a head +/// at `head_created_at`: `max(now, head_created_at + 1)` (Unix seconds). +fn canvas_write_created_at(head_created_at: u64) -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + now.max(head_created_at.saturating_add(1)) +} + /// Build a NIP-01 profile metadata event (kind 0). /// /// Only present (Some) fields are included in the JSON object. @@ -3024,6 +3056,28 @@ mod tests { assert!(has_tag(&create, "expected-revision", "none")); } + #[test] + fn set_canvas_after_head_pins_revision_and_bumps_timestamp() { + let cid = uuid(); + let head = event_id().to_hex(); + + // Head created far in the future relative to the signer's clock: the + // discipline must still stamp strictly ahead of the asserted head. + let future_head = 4_000_000_000_u64; + let ev = sign(build_set_canvas_after_head(cid, "# Restored", &head, future_head).unwrap()); + assert!(has_tag(&ev, "expected-revision", &head)); + assert!( + ev.created_at.as_secs() >= future_head + 1, + "created_at {} must be strictly ahead of future head {future_head}", + ev.created_at.as_secs() + ); + + // Head in the past: the signer's `now` wins and is still ahead. + let past_head = 1_000_u64; + let ev = sign(build_set_canvas_after_head(cid, "# Restored", &head, past_head).unwrap()); + assert!(ev.created_at.as_secs() > past_head); + } + #[test] fn profile_all_fields() { let ev = sign( From 5158994979a006d7302d456c684084708e53592b Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 25 Aug 2026 12:34:47 -0400 Subject: [PATCH 04/44] fix(canvas): satisfy clippy int_plus_one in SDK test assertion Use `> future_head` instead of `>= future_head + 1`. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-sdk/src/builders.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 47bffb70783..ecbe999caa6 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -3067,7 +3067,7 @@ mod tests { let ev = sign(build_set_canvas_after_head(cid, "# Restored", &head, future_head).unwrap()); assert!(has_tag(&ev, "expected-revision", &head)); assert!( - ev.created_at.as_secs() >= future_head + 1, + ev.created_at.as_secs() > future_head, "created_at {} must be strictly ahead of future head {future_head}", ev.created_at.as_secs() ); From 7e9c1b95d7eeca365e99b7c30b3fa65ccafa13f3 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 25 Aug 2026 13:25:10 -0400 Subject: [PATCH 05/44] fix(canvas): validate SDK canvas inputs and harden CLI restore/query paths Validate expected_revision in build_set_canvas as the literal "none" or a 64-hex event id (rejecting locally with InvalidInput like neighboring typed builders instead of shipping a signed event guaranteed to fail at the relay), and reject head_created_at == u64::MAX since saturating_add(1) cannot stamp strictly ahead of it. Short-circuit CLI restore when the target revision already is the head to avoid growing history with a redundant identical revision. Surface a parse error from the canvas head/revision query helpers instead of swallowing malformed relay responses as "no events," which previously misreported a live revision as not found. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/channels.rs | 20 +++++++++-- crates/buzz-sdk/src/builders.rs | 44 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 3a33bd81cdb..c25e5b6d839 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -282,7 +282,11 @@ async fn fetch_canvas_head( "limit": 1, }); let resp = client.query(&filter).await?; - let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + let events: Vec = serde_json::from_str(&resp).map_err(|e| { + CliError::Other(format!( + "malformed relay response querying canvas head: {e}" + )) + })?; Ok(events.into_iter().next()) } @@ -302,7 +306,11 @@ async fn fetch_canvas_revision( "limit": 1, }); let resp = client.query(&filter).await?; - let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + let events: Vec = serde_json::from_str(&resp).map_err(|e| { + CliError::Other(format!( + "malformed relay response querying canvas revision: {e}" + )) + })?; Ok(events.into_iter().next()) } @@ -399,6 +407,14 @@ pub async fn cmd_restore_canvas( .ok_or_else(|| CliError::Other(format!("no canvas head found for channel {channel_id}")))?; let head_created_at = head.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + // Restoring the revision that is already the head would publish a new event + // with identical content, growing history with a redundant revision. Match + // Desktop, which hides Restore on the current revision, by short-circuiting. + if head_id.eq_ignore_ascii_case(revision) { + println!("revision {revision} is already the current revision"); + return Ok(()); + } + let builder = buzz_sdk::build_set_canvas_after_head(channel_uuid, &content, head_id, head_created_at) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index ecbe999caa6..98a1505a5d5 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -567,6 +567,14 @@ pub fn build_set_canvas( ) -> Result { let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; if let Some(expected_revision) = expected_revision { + if expected_revision != "none" + && (expected_revision.len() != 64 + || !expected_revision.chars().all(|c| c.is_ascii_hexdigit())) + { + return Err(SdkError::InvalidInput(format!( + "expected_revision must be the literal \"none\" or a 64-character hex event id (got {expected_revision:?})" + ))); + } tags.push(tag(&["expected-revision", expected_revision])?); } Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags)) @@ -589,6 +597,12 @@ pub fn build_set_canvas_after_head( head_id: &str, head_created_at: u64, ) -> Result { + if head_created_at == u64::MAX { + return Err(SdkError::InvalidInput( + "head_created_at must be below u64::MAX so the write can stamp strictly ahead of it" + .into(), + )); + } let created_at = canvas_write_created_at(head_created_at); Ok(build_set_canvas(channel_id, content, Some(head_id))? .custom_created_at(nostr::Timestamp::from(created_at))) @@ -3078,6 +3092,36 @@ mod tests { assert!(ev.created_at.as_secs() > past_head); } + #[test] + fn set_canvas_rejects_malformed_expected_revision() { + let cid = uuid(); + // Wrong length (63 hex chars). + assert!(matches!( + build_set_canvas(cid, "x", Some(&"a".repeat(63))), + Err(SdkError::InvalidInput(_)) + )); + // Correct length but non-hex. + assert!(matches!( + build_set_canvas(cid, "x", Some(&"z".repeat(64))), + Err(SdkError::InvalidInput(_)) + )); + // Literal "none" and a valid 64-hex id are accepted. + assert!(build_set_canvas(cid, "x", Some("none")).is_ok()); + assert!(build_set_canvas(cid, "x", Some(&"a".repeat(64))).is_ok()); + } + + #[test] + fn set_canvas_after_head_rejects_max_head_created_at() { + let cid = uuid(); + let head = event_id().to_hex(); + // u64::MAX cannot be stamped strictly ahead of: reject instead of + // silently saturating and breaking the head-advancement guarantee. + assert!(matches!( + build_set_canvas_after_head(cid, "# Restored", &head, u64::MAX), + Err(SdkError::InvalidInput(_)) + )); + } + #[test] fn profile_all_fields() { let ev = sign( From 9f8b636a2207b0344ed6d5211a606887a278f149 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 10:41:33 -0400 Subject: [PATCH 06/44] feat(desktop): canvas version history, restore, and conflict-checked save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the canvas head event id through the TS API and send it as the optimistic-concurrency `expected-revision` on every save. When a concurrent edit moved the head, the relay reject surfaces as a distinct "canvas changed — reload" state instead of a generic error. Add a get_canvas_history command over the retained kind:40100 stream and a history panel with author, timestamp, a line diff against the current content, and a Restore action. Restore publishes a new head carrying the selected revision's content under the same conflict guard — it never mutates or deletes history. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/package.json | 3 +- desktop/src-tauri/src/commands/canvas.rs | 113 ++++++++- desktop/src-tauri/src/events.rs | 16 +- desktop/src-tauri/src/lib.rs | 1 + .../features/channels/canvasConflict.test.mjs | 56 +++++ .../src/features/channels/canvasConflict.ts | 57 +++++ desktop/src/features/channels/canvasHooks.ts | 75 ++++++ desktop/src/features/channels/hooks.ts | 42 +--- .../channels/ui/CanvasHistoryPanel.tsx | 237 ++++++++++++++++++ .../features/channels/ui/ChannelCanvas.tsx | 54 +++- .../ui/ChannelCanvasEditSnapshot.test.mjs | 190 ++++++++++++++ desktop/src/shared/api/canvasTypes.ts | 36 +++ .../src/shared/api/relayQueryInvalidation.ts | 1 + desktop/src/shared/api/tauri.ts | 46 +--- desktop/src/shared/api/tauriCanvas.ts | 88 +++++++ desktop/src/shared/api/types.ts | 22 +- desktop/src/testing/e2eBridge.ts | 140 ++++++++++- desktop/tests/e2e/channels.spec.ts | 78 ++++++ desktop/tests/helpers/bridge.ts | 7 + pnpm-lock.yaml | 37 +-- 20 files changed, 1164 insertions(+), 135 deletions(-) create mode 100644 desktop/src/features/channels/canvasConflict.test.mjs create mode 100644 desktop/src/features/channels/canvasConflict.ts create mode 100644 desktop/src/features/channels/canvasHooks.ts create mode 100644 desktop/src/features/channels/ui/CanvasHistoryPanel.tsx create mode 100644 desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs create mode 100644 desktop/src/shared/api/canvasTypes.ts create mode 100644 desktop/src/shared/api/tauriCanvas.ts diff --git a/desktop/package.json b/desktop/package.json index 3d6024e1053..4db89f63e9c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -63,7 +63,7 @@ "@tiptap/starter-kit": "^3.22.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.4.0", + "diff": "^8.0.4", "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", @@ -74,7 +74,6 @@ "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react": "^19.1.0", - "react-day-picker": "^10.0.1", "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs index 191fafe60d8..08c8bc15a3a 100644 --- a/desktop/src-tauri/src/commands/canvas.rs +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -3,6 +3,7 @@ use tauri::State; use crate::{ app_state::AppState, events, + managed_agents::persona_events::monotonic_created_at, relay::{query_relay, submit_event}, }; @@ -46,11 +47,27 @@ pub async fn get_canvas( pub async fn set_canvas( channel_id: String, content: String, + expected_revision: Option, state: State<'_, AppState>, ) -> Result { let uuid = uuid::Uuid::parse_str(&channel_id) .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; - let builder = events::build_set_canvas(uuid, &content)?; + + // Writer discipline (contract v3): sign `created_at = max(now, head + 1)` + // so an accepted tagged write always sorts strictly ahead of the head it + // asserts (`created_at DESC, id ASC`). Without this, a same-second or + // behind-clock writer could satisfy the precondition yet lose the relay's + // tiebreak, "succeeding" without changing the visible canvas. Only a real + // head id has a timestamp to clear; `none`/absent asserts no prior head. + let prior_head_created_at = match expected_revision.as_deref() { + Some(rev) if rev.len() == 64 && rev.bytes().all(|b| b.is_ascii_hexdigit()) => { + asserted_head_created_at(&state, &channel_id, rev).await? + } + _ => None, + }; + + let builder = events::build_set_canvas(uuid, &content, expected_revision.as_deref())? + .custom_created_at(monotonic_created_at(prior_head_created_at)); let result = submit_event(builder, &state).await?; Ok(serde_json::json!({ @@ -58,3 +75,97 @@ pub async fn set_canvas( "event_id": result.event_id, })) } + +/// `created_at` of the asserted head, or `None` if the relay no longer holds +/// that revision. An id-scoped query is immutable, so the answer cannot shift +/// under a concurrent write; a missing head lets the relay surface the +/// `conflict: canvas revision does not exist` reject on submit rather than +/// masking it with a stale floor here. +async fn asserted_head_created_at( + state: &AppState, + channel_id: &str, + revision: &str, +) -> Result, String> { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "ids": [revision], + "limit": 1 + })], + ) + .await?; + Ok(events + .first() + .map(|event| event.created_at.as_secs() as i64)) +} + +/// One page of a channel canvas's revision stream (kind:40100), newest first. +/// Each 40100 write is a regular signed event the relay retains, so the +/// standard query surface holds the complete history. The composite +/// `(until, before_id)` cursor mirrors the relay read order +/// (`created_at DESC, id ASC`) so paging never skips or repeats a revision when +/// several share the same second. `next_cursor` is present only when a full +/// page came back, i.e. older revisions may remain. +#[tauri::command] +pub async fn get_canvas_history( + channel_id: String, + limit: Option, + until: Option, + before_id: Option, + state: State<'_, AppState>, +) -> Result { + if before_id.is_some() && until.is_none() { + return Err("before_id requires until".to_string()); + } + let page_size = limit.unwrap_or(100).max(1); + + let mut filter = serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": page_size, + }); + if let Some(value) = until { + filter["until"] = serde_json::json!(value); + } + if let Some(ref value) = before_id { + if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("before_id must be a 64-character hex event id".to_string()); + } + filter["before_id"] = serde_json::json!(value); + } + + let events = query_relay(&state, &[filter]).await?; + + let revisions: Vec = events + .iter() + .map(|event| { + serde_json::json!({ + "event_id": event.id.to_hex(), + "content": event.content, + "created_at": event.created_at.as_secs(), + "author": event.pubkey.to_hex(), + }) + }) + .collect(); + + // A full page means the relay may hold older revisions; hand back the + // last event as the cursor for the next "Load older" request. A short page + // is the tail, so there is no next cursor. + let next_cursor = if events.len() == page_size { + events.last().map(|last| { + serde_json::json!({ + "created_at": last.created_at.as_secs(), + "event_id": last.id.to_hex(), + }) + }) + } else { + None + }; + + Ok(serde_json::json!({ + "revisions": revisions, + "next_cursor": next_cursor, + })) +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 1828b3f5605..c9ae5811d5c 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -416,9 +416,21 @@ pub fn build_remove_reaction(reaction_event_id: EventId) -> Result Result { +/// +/// When `expected_revision` is `Some`, an `["expected-revision", ]` +/// tag is attached so the relay can reject the write if the canvas head moved +/// since the client loaded it (optimistic concurrency). Omitting it preserves +/// the historical unconditional-append behavior. +pub fn build_set_canvas( + channel_id: Uuid, + content: &str, + expected_revision: Option<&str>, +) -> Result { check_content(content)?; - let tags = vec![tag(vec!["h", &channel_id.to_string()])?]; + let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; + if let Some(revision) = expected_revision { + tags.push(tag(vec!["expected-revision", revision])?); + } Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags)) } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 12082a2a82e..1ec30ceb1cf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -640,6 +640,7 @@ pub fn run() { join_channel, leave_channel, get_canvas, + get_canvas_history, set_canvas, get_feed, search_messages, diff --git a/desktop/src/features/channels/canvasConflict.test.mjs b/desktop/src/features/channels/canvasConflict.test.mjs new file mode 100644 index 00000000000..b2eaa9edcb1 --- /dev/null +++ b/desktop/src/features/channels/canvasConflict.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CANVAS_EXPECTED_REVISION_NONE, + isCanvasConflictError, +} from "./canvasConflict.ts"; + +// The three frozen relay reject strings are all conflicts from the user's +// perspective: the head moved, the revision the client expected no longer +// exists, or the write does not sort strictly ahead of the current head +// (contract v3). The helper must recognize each whether it arrives as an Error +// or a raw string (the Tauri IPC layer hands back either), and must not misfire +// on unrelated errors. + +test("head-moved reject is a conflict as Error and as raw string", () => { + const message = "conflict: canvas changed since it was loaded"; + assert.equal(isCanvasConflictError(new Error(message)), true); + assert.equal(isCanvasConflictError(message), true); +}); + +test("revision-does-not-exist reject is a conflict as Error and as raw string", () => { + const message = "conflict: canvas revision does not exist"; + assert.equal(isCanvasConflictError(new Error(message)), true); + assert.equal(isCanvasConflictError(message), true); +}); + +test("does-not-supersede reject is a conflict as Error and as raw string", () => { + const message = "conflict: canvas write does not supersede the current head"; + assert.equal(isCanvasConflictError(new Error(message)), true); + assert.equal(isCanvasConflictError(message), true); +}); + +test("conflict marker embedded in a longer wrapped message still matches", () => { + const wrapped = new Error( + "submit failed: conflict: canvas revision does not exist (relay)", + ); + assert.equal(isCanvasConflictError(wrapped), true); +}); + +test("unrelated errors are not conflicts", () => { + assert.equal(isCanvasConflictError(new Error("relay unreachable")), false); + assert.equal(isCanvasConflictError("some other failure"), false); + assert.equal(isCanvasConflictError(null), false); + assert.equal(isCanvasConflictError(undefined), false); + assert.equal( + isCanvasConflictError({ + message: "conflict: canvas changed since it was loaded", + }), + false, + ); +}); + +test("the create-race sentinel is the literal contract value", () => { + assert.equal(CANVAS_EXPECTED_REVISION_NONE, "none"); +}); diff --git a/desktop/src/features/channels/canvasConflict.ts b/desktop/src/features/channels/canvasConflict.ts new file mode 100644 index 00000000000..264388986f8 --- /dev/null +++ b/desktop/src/features/channels/canvasConflict.ts @@ -0,0 +1,57 @@ +/** + * Optimistic-concurrency conflict detection for the channel canvas. + * + * A conflict-checked save (`set_canvas` / restore) sends an + * `["expected-revision", ]` tag. The relay rejects the + * write when the live head no longer matches what the client loaded, and the + * Rust submit path surfaces that as an error whose message contains one of the + * frozen relay strings below. Callers use this to render a distinct "canvas + * changed — reload" state instead of a generic error. + * + * Two reject strings are both conflicts from the user's perspective: + * - the head moved since load, and + * - the revision the client expected no longer exists (e.g. it expected a head + * but the canvas was never created, or was replaced out from under it). + * A third arises under contract v3's head-advancement guarantee: a write whose + * precondition matches but which does not sort strictly ahead of the asserted + * head (`created_at DESC, id ASC`) is rejected so an accepted tagged write is + * always the new visible head. + * + * Contract: the relay reject strings are frozen (`crates/**`, Duncan's PR1). Do + * not change these substrings without updating the relay in lockstep. + */ +const CANVAS_CONFLICT_MARKERS = [ + "conflict: canvas changed since it was loaded", + "conflict: canvas revision does not exist", + "conflict: canvas write does not supersede the current head", +] as const; + +export const CANVAS_CONFLICT_MESSAGE = + "This canvas changed since you loaded it — reload to see the latest, then reapply your edit."; + +/** + * Literal `expected-revision` value asserting "I expect no canvas exists yet". + * Sent by the first save of a new canvas so a concurrent first creation is + * rejected as a conflict rather than silently overwritten. Frozen contract + * value (`crates/**`, Duncan's PR1). + */ +export const CANVAS_EXPECTED_REVISION_NONE = "none"; + +/** + * True when `error` is the relay's optimistic-concurrency conflict — the head + * moved or the expected revision no longer exists between the load and the + * save. Accepts `Error` instances and raw strings so callers can pass whatever + * the Tauri IPC layer hands them. + */ +export function isCanvasConflictError(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : null; + if (message === null) { + return false; + } + return CANVAS_CONFLICT_MARKERS.some((marker) => message.includes(marker)); +} diff --git a/desktop/src/features/channels/canvasHooks.ts b/desktop/src/features/channels/canvasHooks.ts new file mode 100644 index 00000000000..9e289a5f38c --- /dev/null +++ b/desktop/src/features/channels/canvasHooks.ts @@ -0,0 +1,75 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +import { getCanvas, getCanvasHistory, setCanvas } from "@/shared/api/tauri"; +import type { + CanvasHistoryCursor, + CanvasHistoryResponse, +} from "@/shared/api/types"; + +export function useCanvasQuery(channelId: string | null, enabled = true) { + return useQuery({ + queryKey: ["channel-canvas", channelId], + queryFn: () => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return getCanvas(channelId); + }, + enabled: enabled && channelId !== null, + }); +} + +export function useCanvasHistoryQuery( + channelId: string | null, + enabled: boolean, +) { + return useInfiniteQuery({ + queryKey: ["channel-canvas-history", channelId], + queryFn: ({ pageParam }) => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return getCanvasHistory(channelId, { + cursor: (pageParam as CanvasHistoryCursor | null) ?? null, + }); + }, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + initialPageParam: null, + enabled: enabled && channelId !== null, + }); +} + +export function useSetCanvasMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: { + content: string; + expectedRevision?: string | null; + }) => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return setCanvas({ + channelId, + content: input.content, + expectedRevision: input.expectedRevision ?? null, + }); + }, + onSuccess: () => { + if (channelId) { + void queryClient.invalidateQueries({ + queryKey: ["channel-canvas", channelId], + }); + void queryClient.invalidateQueries({ + queryKey: ["channel-canvas-history", channelId], + }); + } + }, + }); +} diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 18eb2699d2e..fb43d00cd94 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -11,7 +11,6 @@ import { archiveChannel, createChannel, deleteChannel, - getCanvas, getChannelDetails, getChannelMembers, getChannels, @@ -21,7 +20,6 @@ import { openDm, invokeTauri, removeChannelMember, - setCanvas, setChannelPurpose, setChannelTopic, unarchiveChannel, @@ -963,35 +961,11 @@ export function useSelectedChannel( } // ── Canvas ──────────────────────────────────────────────────────────────────── -export function useCanvasQuery(channelId: string | null, enabled = true) { - return useQuery({ - queryKey: ["channel-canvas", channelId], - queryFn: () => { - if (!channelId) { - return Promise.reject(new Error("No channel selected")); - } - return getCanvas(channelId); - }, - enabled: enabled && channelId !== null, - }); -} - -export function useSetCanvasMutation(channelId: string | null) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (content: string) => { - if (!channelId) { - return Promise.reject(new Error("No channel selected")); - } - return setCanvas({ channelId, content }); - }, - onSuccess: () => { - if (channelId) { - void queryClient.invalidateQueries({ - queryKey: ["channel-canvas", channelId], - }); - } - }, - }); -} +// Canvas query/mutation hooks live in their own module to keep this file under +// the desktop file-size ratchet; re-exported here so existing import paths +// (`@/features/channels/hooks`) keep working. +export { + useCanvasHistoryQuery, + useCanvasQuery, + useSetCanvasMutation, +} from "@/features/channels/canvasHooks"; diff --git a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx new file mode 100644 index 00000000000..2d50ac82197 --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx @@ -0,0 +1,237 @@ +import { diffLines } from "diff"; +import { RotateCcw } from "lucide-react"; +import * as React from "react"; + +import { + useCanvasHistoryQuery, + useSetCanvasMutation, +} from "@/features/channels/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import type { CanvasRevision } from "@/shared/api/types"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { + isRelayUnreachableError, + RELAY_UNREACHABLE_SHORT, +} from "@/shared/lib/relayError"; +import { + CANVAS_CONFLICT_MESSAGE, + CANVAS_EXPECTED_REVISION_NONE, + isCanvasConflictError, +} from "@/features/channels/canvasConflict"; +import { Button } from "@/shared/ui/button"; + +type CanvasHistoryPanelProps = { + channelId: string; + currentContent: string; + currentRevision: string | null; + canRestore: boolean; +}; + +/** + * Revision history for a channel canvas. Every kind:40100 write is a regular + * signed event the relay retains, so the list is the complete edit stream — + * newest first, the head marked "Current". Selecting an older revision reveals + * a line diff against the current content and (when the viewer can edit) a + * Restore action. + * + * Restore never mutates history: it publishes a new head carrying the selected + * revision's content, guarded by `expected-revision` = the current head so a + * concurrent edit surfaces the same conflict state as a normal save. + */ +export function CanvasHistoryPanel({ + channelId, + currentContent, + currentRevision, + canRestore, +}: CanvasHistoryPanelProps) { + const historyQuery = useCanvasHistoryQuery(channelId, true); + const restoreMutation = useSetCanvasMutation(channelId); + const [selectedId, setSelectedId] = React.useState(null); + + const revisions = React.useMemo( + () => historyQuery.data?.pages.flatMap((page) => page.revisions) ?? [], + [historyQuery.data], + ); + const authorPubkeys = React.useMemo( + () => revisions.map((revision) => revision.author), + [revisions], + ); + const profilesQuery = useUsersBatchQuery(authorPubkeys, { + enabled: authorPubkeys.length > 0, + }); + + function authorLabel(pubkey: string): string { + const summary = profilesQuery.data?.profiles[pubkey.toLowerCase()]; + return summary?.displayName?.trim() || truncatePubkey(pubkey); + } + + async function handleRestore(revision: CanvasRevision) { + // Restore is a conflict-checked publish against the live head: if the + // canvas moved since this panel loaded, the relay rejects and we surface + // the same reload state as a normal save. + await restoreMutation.mutateAsync({ + content: revision.content, + expectedRevision: currentRevision ?? CANVAS_EXPECTED_REVISION_NONE, + }); + setSelectedId(null); + } + + if (historyQuery.isLoading) { + return

Loading history...

; + } + + if (historyQuery.error instanceof Error) { + return ( +

+ {isRelayUnreachableError(historyQuery.error) + ? RELAY_UNREACHABLE_SHORT + : historyQuery.error.message} +

+ ); + } + + if (revisions.length === 0) { + return

No revisions yet.

; + } + + return ( +
+
    + {revisions.map((revision) => { + const isCurrent = revision.eventId === currentRevision; + const isSelected = revision.eventId === selectedId; + return ( +
  • + + {isSelected ? ( +
    + + {canRestore && !isCurrent ? ( + + ) : null} + {restoreMutation.error instanceof Error ? ( +

    + {isCanvasConflictError(restoreMutation.error) + ? CANVAS_CONFLICT_MESSAGE + : restoreMutation.error.message} +

    + ) : null} +
    + ) : null} +
  • + ); + })} +
+ {historyQuery.hasNextPage ? ( + + ) : null} +
+ ); +} + +/** + * Line-level diff of a past revision against the current canvas content. + * Additions are the revision's lines not in current; removals are current + * lines the revision drops. Unchanged runs render muted for context. + */ +function CanvasRevisionDiff({ + current, + revision, +}: { + current: string; + revision: string; +}) { + const parts = React.useMemo( + () => diffLines(current, revision), + [current, revision], + ); + if (parts.length === 1 && !parts[0].added && !parts[0].removed) { + return ( +

+ Identical to the current canvas. +

+ ); + } + // Each part covers a distinct, non-overlapping slice of the concatenated + // diff, so its cumulative character offset is a stable, unique key. + let offset = 0; + return ( +
+      {parts.map((part) => {
+        const prefix = part.added ? "+" : part.removed ? "-" : " ";
+        const tone = part.added
+          ? "text-emerald-600 dark:text-emerald-400"
+          : part.removed
+            ? "text-destructive"
+            : "text-muted-foreground";
+        const key = `${prefix}${offset}`;
+        offset += part.value.length;
+        return (
+          
+            {part.value
+              .replace(/\n$/, "")
+              .split("\n")
+              .map((line) => `${prefix} ${line}`)
+              .join("\n")}
+            {"\n"}
+          
+        );
+      })}
+    
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelCanvas.tsx b/desktop/src/features/channels/ui/ChannelCanvas.tsx index 785be39227b..e3b928bb0e4 100644 --- a/desktop/src/features/channels/ui/ChannelCanvas.tsx +++ b/desktop/src/features/channels/ui/ChannelCanvas.tsx @@ -1,4 +1,4 @@ -import { Pencil, Save, X } from "lucide-react"; +import { History, Pencil, Save, X } from "lucide-react"; import * as React from "react"; import { @@ -13,6 +13,12 @@ import { isRelayUnreachableError, RELAY_UNREACHABLE_SHORT, } from "@/shared/lib/relayError"; +import { + CANVAS_CONFLICT_MESSAGE, + CANVAS_EXPECTED_REVISION_NONE, + isCanvasConflictError, +} from "@/features/channels/canvasConflict"; +import { CanvasHistoryPanel } from "./CanvasHistoryPanel"; type ChannelCanvasProps = { channelId: string | null; @@ -33,15 +39,25 @@ export function ChannelCanvas({ [channels], ); const [isEditing, setIsEditing] = React.useState(false); + const [showHistory, setShowHistory] = React.useState(false); const [draft, setDraft] = React.useState(""); + // Head event id captured at edit-start. A background canvas refetch can move + // the live head mid-edit; the save must assert against what the editor + // actually loaded, not the latest head. `null` means "no canvas existed when + // I started" and maps to the `none` create-race sentinel below. + const [editBaseRevision, setEditBaseRevision] = React.useState( + null, + ); const canvasContent = canvasQuery.data?.content ?? null; + const canvasRevision = canvasQuery.data?.eventId ?? null; // Defer the single large Markdown parse so opening the canvas commits the // surrounding chrome immediately and the heavy render reconciles after. const deferredCanvasContent = React.useDeferredValue(canvasContent); function handleStartEditing() { setDraft(canvasContent ?? ""); + setEditBaseRevision(canvasRevision); setIsEditing(true); } @@ -51,7 +67,14 @@ export function ChannelCanvas({ } async function handleSave() { - await setCanvasMutation.mutateAsync(draft); + // Assert against the head snapshotted at edit-start, not the live head — + // a refetch may have moved `canvasRevision` while the editor was open. + // A null snapshot means no canvas existed then, so send the `none` + // sentinel to close the concurrent-first-creation race. + await setCanvasMutation.mutateAsync({ + content: draft, + expectedRevision: editBaseRevision ?? CANVAS_EXPECTED_REVISION_NONE, + }); setIsEditing(false); } @@ -110,7 +133,9 @@ export function ChannelCanvas({ {setCanvasMutation.error instanceof Error ? (

- {setCanvasMutation.error.message} + {isCanvasConflictError(setCanvasMutation.error) + ? CANVAS_CONFLICT_MESSAGE + : setCanvasMutation.error.message}

) : null} @@ -146,6 +171,29 @@ export function ChannelCanvas({ {canvasContent ? "Edit canvas" : "Create canvas"} ) : null} + {canvasContent ? ( + <> + + {showHistory && channelId ? ( + + ) : null} + + ) : null} ); } diff --git a/desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs b/desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs new file mode 100644 index 00000000000..a9bd1912a9d --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs @@ -0,0 +1,190 @@ +/** + * Edit-session snapshot regression: ChannelCanvas must assert the save against + * the head that existed when editing started, not the live head. A background + * canvas refetch can move the head mid-edit; without the snapshot the save + * would silently overwrite the newer revision instead of surfacing a conflict. + * + * Mounts the shipping ChannelCanvas, opens the editor at head A, moves the live + * head to B via a refetch, then saves and asserts the submitted + * `expectedRevision` is still A. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// The real Markdown component pulls in the remark/rehype/emoji stack, which +// never releases its jsdom handles and hangs the node:test process. This test +// only exercises the save-snapshot wiring, so serve an inert stub. +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD_A = "a".repeat(64); +const HEAD_B = "b".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let ChannelCanvas; + +// Mutable relay-head mock the Tauri bridge reads on each get_canvas. +let currentHead = { content: "original", eventId: HEAD_A }; +const setCanvasCalls = []; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + + // Stub the Tauri IPC bridge invokeTauri ultimately calls. + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + if (cmd === "get_canvas") { + return { + content: currentHead.content, + event_id: currentHead.eventId, + updated_at: 1, + author: HEAD_A, + }; + } + if (cmd === "set_canvas") { + setCanvasCalls.push(args); + return { ok: true, event_id: HEAD_B }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ ChannelCanvas } = await import("./ChannelCanvas.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +// Flush microtasks, pending query promises, and React's scheduler (the +// deferred canvas render is posted on a MessageChannel) so nothing is left +// pending at teardown. +async function settle(iterations = 6) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +test("head moves mid-edit — save still asserts the head snapshotted at edit-start", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + // gcTime: 0 lets the settled mutation drop from cache immediately so no + // mutation promise is left pending when the node:test process tears down. + mutations: { gcTime: 0 }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ); + }); + + // Canvas at head A has loaded; open the editor. + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + const editButton = container.querySelector( + "[data-testid='channel-canvas-edit']", + ); + assert.ok(editButton, "edit button renders after head A loads"); + await act(async () => click(editButton)); + assert.ok(container.querySelector("[data-testid='channel-canvas-editor']")); + + // Head moves to B under the open editor via a background refetch. + currentHead = { content: "moved", eventId: HEAD_B }; + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + + // Save — the submitted expected revision must be the snapshot (A), not B. + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + + assert.equal(setCanvasCalls.length, 1); + assert.equal(setCanvasCalls[0].expectedRevision, HEAD_A); + + // Drain the refetch the save's onSuccess invalidation triggers, plus any + // deferred render still scheduled, so no work is pending at teardown. + await settle(12); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); diff --git a/desktop/src/shared/api/canvasTypes.ts b/desktop/src/shared/api/canvasTypes.ts new file mode 100644 index 00000000000..f907a80d797 --- /dev/null +++ b/desktop/src/shared/api/canvasTypes.ts @@ -0,0 +1,36 @@ +export type CanvasResponse = { + content: string | null; + eventId: string | null; + updatedAt: number | null; + author: string | null; +}; + +export type SetCanvasInput = { + channelId: string; + content: string; + expectedRevision?: string | null; +}; + +export type SetCanvasResult = { + ok: boolean; + eventId: string; +}; + +export type CanvasRevision = { + eventId: string; + content: string; + createdAt: number; + author: string; +}; + +/** Composite `(created_at DESC, id ASC)` cursor for "Load older" paging. */ +export type CanvasHistoryCursor = { + createdAt: number; + eventId: string; +}; + +export type CanvasHistoryResponse = { + revisions: CanvasRevision[]; + /** Present only when older revisions may remain. */ + nextCursor: CanvasHistoryCursor | null; +}; diff --git a/desktop/src/shared/api/relayQueryInvalidation.ts b/desktop/src/shared/api/relayQueryInvalidation.ts index b42d9f39612..cb6e197a7b2 100644 --- a/desktop/src/shared/api/relayQueryInvalidation.ts +++ b/desktop/src/shared/api/relayQueryInvalidation.ts @@ -1,6 +1,7 @@ const RELAY_QUERY_ROOTS = new Set([ "archivedIdentities", "channel-canvas", + "channel-canvas-history", "channel-messages", "channels", "contact-list", diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 984b9d176df..a4db3a39559 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -12,7 +12,6 @@ import type { AddChannelMembersResult, BackendProviderCandidate, BackendProviderProbeResult, - CanvasResponse, GetHomeFeedInput, HomeFeedResponse, ManagedAgent, @@ -25,8 +24,6 @@ import type { RelayEvent, SearchMessagesInput, SearchMessagesResponse, - SetCanvasInput, - SetCanvasResult, ThreadCursor, ThreadRepliesResponse, CreateManagedAgentInput, @@ -42,6 +39,11 @@ import type { } from "@/shared/api/types"; export * from "@/shared/api/tauriChannels"; +export { + getCanvas, + getCanvasHistory, + setCanvas, +} from "@/shared/api/tauriCanvas"; export { sendChannelMessage } from "@/shared/api/tauriMessages"; export { getEventById, getEventsByIds } from "@/shared/api/tauriEvents"; @@ -233,17 +235,6 @@ type RawListRelayMembersResponse = { members: RawRelayMember[]; }; -type RawCanvasResponse = { - content: string | null; - updated_at: number | null; - author: string | null; -}; - -type RawSetCanvasResult = { - ok: boolean; - event_id: string; -}; - /** Error normalized from a rejected Tauri invocation with its wire payload. */ export class TauriInvokeError extends Error { readonly payload: unknown; @@ -400,33 +391,6 @@ export async function leaveChannel(channelId: string): Promise { await invokeTauri("leave_channel", { channelId }); } -export async function getCanvas(channelId: string): Promise { - const response = await invokeTauri("get_canvas", { - channelId, - }); - return { - content: response.content, - // Normalize absent keys to null: ensureWelcomeCanvas treats null as - // "no canvas yet", and `undefined !== null` would make every fresh - // channel look already-seeded. - updatedAt: response.updated_at ?? null, - author: response.author ?? null, - }; -} - -export async function setCanvas( - input: SetCanvasInput, -): Promise { - const response = await invokeTauri("set_canvas", { - channelId: input.channelId, - content: input.content, - }); - return { - ok: response.ok, - eventId: response.event_id, - }; -} - export async function getHomeFeed( input: GetHomeFeedInput = {}, ): Promise { diff --git a/desktop/src/shared/api/tauriCanvas.ts b/desktop/src/shared/api/tauriCanvas.ts new file mode 100644 index 00000000000..435f235c0af --- /dev/null +++ b/desktop/src/shared/api/tauriCanvas.ts @@ -0,0 +1,88 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { + CanvasHistoryCursor, + CanvasHistoryResponse, + CanvasResponse, + SetCanvasInput, + SetCanvasResult, +} from "@/shared/api/canvasTypes"; + +type RawCanvasResponse = { + content: string | null; + event_id: string | null; + updated_at: number | null; + author: string | null; +}; + +type RawCanvasHistoryResponse = { + revisions: { + event_id: string; + content: string; + created_at: number; + author: string; + }[]; + next_cursor: { created_at: number; event_id: string } | null; +}; + +type RawSetCanvasResult = { + ok: boolean; + event_id: string; +}; + +export async function getCanvas(channelId: string): Promise { + const response = await invokeTauri("get_canvas", { + channelId, + }); + return { + content: response.content, + eventId: response.event_id ?? null, + // Normalize absent keys to null: ensureWelcomeCanvas treats null as + // "no canvas yet", and `undefined !== null` would make every fresh + // channel look already-seeded. + updatedAt: response.updated_at ?? null, + author: response.author ?? null, + }; +} + +export async function setCanvas( + input: SetCanvasInput, +): Promise { + const response = await invokeTauri("set_canvas", { + channelId: input.channelId, + content: input.content, + expectedRevision: input.expectedRevision ?? null, + }); + return { + ok: response.ok, + eventId: response.event_id, + }; +} + +export async function getCanvasHistory( + channelId: string, + options: { limit?: number; cursor?: CanvasHistoryCursor | null } = {}, +): Promise { + const response = await invokeTauri( + "get_canvas_history", + { + channelId, + limit: options.limit ?? null, + until: options.cursor?.createdAt ?? null, + beforeId: options.cursor?.eventId ?? null, + }, + ); + return { + revisions: response.revisions.map((revision) => ({ + eventId: revision.event_id, + content: revision.content, + createdAt: revision.created_at, + author: revision.author, + })), + nextCursor: response.next_cursor + ? { + createdAt: response.next_cursor.created_at, + eventId: response.next_cursor.event_id, + } + : null, + }; +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index b988843d60b..6e8ab3730aa 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -69,21 +69,15 @@ export type SetChannelPurposeInput = { purpose: string; }; -export type CanvasResponse = { - content: string | null; - updatedAt: number | null; - author: string | null; -}; - -export type SetCanvasInput = { - channelId: string; - content: string; -}; +export type { + CanvasHistoryCursor, + CanvasHistoryResponse, + CanvasResponse, + CanvasRevision, + SetCanvasInput, + SetCanvasResult, +} from "@/shared/api/canvasTypes"; -export type SetCanvasResult = { - ok: boolean; - eventId: string; -}; export type AddChannelMembersInput = { channelId: string; pubkeys: string[]; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b74d0342080..2c868c958ba 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -371,6 +371,10 @@ type E2eConfig = { deepHistoryMessageCount?: number; feedReadError?: string; canvasReadError?: string; + /** Seed canvas revisions (oldest first) so history/restore journeys can + * drive the real panel against a stateful store. Each save appends a new + * head; `get_canvas_history` pages over the accumulated stream. */ + canvasRevisions?: MockCanvasRevisionSeed[]; /** Delay (ms) for `apply_workspace` so e2e tests can observe the * community-switch gate. 0/undefined = instant. */ applyCommunityDelayMs?: number; @@ -3283,6 +3287,49 @@ type MockSaveSubscriptionRow = { }; let mockSaveSubscriptions: MockSaveSubscriptionRow[] = []; +// Stateful mock canvas: an append-only revision stream keyed by channel, newest +// first, mirroring the relay's 40100 history. `get_canvas` returns the head, +// `set_canvas` appends a new head, and `get_canvas_history` pages over the +// stream with the same `(created_at DESC, id ASC)` composite cursor the Rust +// command uses — so history/save-conflict/restore journeys run against real +// state instead of a fixed stub. +type MockCanvasRevisionSeed = { + content: string; + /** Optional; defaults to a monotonically increasing second per seed. */ + createdAt?: number; + /** Optional 64-hex id; defaults to a fresh mock event id. */ + eventId?: string; + /** Optional author pubkey; defaults to the mock viewer. */ + author?: string; +}; +type MockCanvasRevision = { + eventId: string; + content: string; + createdAt: number; + author: string; +}; +let mockCanvasRevisions = new Map(); + +// The canvas UI reaches the mock via the starter "general" channel in specs. +const DEFAULT_STARTER_CANVAS_CHANNEL = STARTER_GENERAL_CHANNEL_ID; + +function resetMockCanvasRevisions(config: E2eConfig | undefined) { + mockCanvasRevisions = new Map(); + const seeds = config?.mock?.canvasRevisions; + if (!seeds || seeds.length === 0) { + return; + } + // Seeds are oldest-first; store newest-first so index 0 is always the head. + const revisions = seeds.map((seed, index) => ({ + eventId: seed.eventId ?? mockEventId(), + content: seed.content, + createdAt: seed.createdAt ?? 1_700_000_000 + index, + author: seed.author ?? DEFAULT_MOCK_IDENTITY.pubkey, + })); + revisions.reverse(); + mockCanvasRevisions.set(DEFAULT_STARTER_CANVAS_CHANNEL, revisions); +} + type MockObservedUnreadScope = { generation: string; revision: number; @@ -11297,6 +11344,7 @@ export function maybeInstallE2eTauriMocks() { resetMockObservedUnread(); resetMockTeamCatalogEvents(config); resetMockSaveSubscriptions(config); + resetMockCanvasRevisions(config); resetMockPendingCommunityDeepLinks(config); resetMockPendingNavigationDeepLinks(config); resetMockPendingEntityDeepLinks(config); @@ -14659,15 +14707,99 @@ export function maybeInstallE2eTauriMocks() { // The spec only verifies UI state, not the submitted request shape; // returning null mirrors the Rust submit_event success path. return null; - case "set_canvas": - return { ok: true, event_id: mockEventId() }; + case "set_canvas": { + const req = payload as { + channelId: string; + content: string; + expectedRevision?: string | null; + }; + const stream = mockCanvasRevisions.get(req.channelId) ?? []; + const head = stream[0] ?? null; + // Mirror the relay's optimistic-concurrency check: the frozen reject + // strings must survive intact so canvasConflict.ts recognizes them. + const expected = req.expectedRevision; + if (expected !== undefined && expected !== null) { + if (expected === "none" && head) { + throw new Error("conflict: canvas changed since it was loaded"); + } + if (expected !== "none" && !head) { + throw new Error("conflict: canvas revision does not exist"); + } + if (expected !== "none" && head && expected !== head.eventId) { + throw new Error("conflict: canvas changed since it was loaded"); + } + } + const revision: MockCanvasRevision = { + eventId: mockEventId(), + content: req.content, + createdAt: (head?.createdAt ?? 1_700_000_000) + 1, + author: DEFAULT_MOCK_IDENTITY.pubkey, + }; + mockCanvasRevisions.set(req.channelId, [revision, ...stream]); + return { ok: true, event_id: revision.eventId }; + } case "get_canvas": { const canvasReadError = activeConfig?.mock?.canvasReadError; if (canvasReadError) { throw new Error(canvasReadError); } - // Return the no-canvas success shape — content null means no canvas set. - return { content: null, updated_at: null, author: null }; + const req = payload as { channelId: string }; + const head = mockCanvasRevisions.get(req.channelId)?.[0] ?? null; + if (!head) { + // No-canvas success shape — content null means no canvas set. + return { + content: null, + event_id: null, + updated_at: null, + author: null, + }; + } + return { + content: head.content, + event_id: head.eventId, + updated_at: head.createdAt, + author: head.author, + }; + } + case "get_canvas_history": { + const req = payload as { + channelId: string; + limit?: number | null; + until?: number | null; + beforeId?: string | null; + }; + const pageSize = Math.max(req.limit ?? 100, 1); + const stream = mockCanvasRevisions.get(req.channelId) ?? []; + // Keyset over the newest-first stream: strictly older than the + // composite cursor, preserving (created_at DESC, id ASC) so a tied + // second never skips or repeats a revision across a page boundary. + const until = req.until; + const beforeId = req.beforeId; + const windowed = + until == null + ? stream + : stream.filter((rev) => { + if (rev.createdAt < until) return true; + if (rev.createdAt > until) return false; + return beforeId != null && rev.eventId > beforeId; + }); + const page = windowed.slice(0, pageSize); + const nextCursor = + page.length === pageSize && page.length > 0 + ? { + created_at: page[page.length - 1].createdAt, + event_id: page[page.length - 1].eventId, + } + : null; + return { + revisions: page.map((rev) => ({ + event_id: rev.eventId, + content: rev.content, + created_at: rev.createdAt, + author: rev.author, + })), + next_cursor: nextCursor, + }; } // ── Local-save archive ────────────────────────────────────────────── // These stubs drive the LocalArchiveSettingsCard in screenshot / UI tests diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 9db4370b7cd..4d75b9f5b21 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -3123,6 +3123,84 @@ test("manage channel places canvas between channel info and actions", async ({ expect(canvasBox?.y).toBeLessThan(leaveBox?.y); }); +test("canvas history, save-conflict, and restore journey", async ({ page }) => { + // Seed a two-revision canvas so the history panel has an older revision to + // diff and restore, and the head is the newer one. + await installMockBridge(page, { + canvasRevisions: [ + { content: "# Kickoff\n\nfirst draft", createdAt: 1_700_000_000 }, + { content: "# Kickoff\n\nsecond draft", createdAt: 1_700_000_100 }, + ], + }); + await page.goto("/"); + await openChannelManagement(page, "general"); + await page.getByTestId("channel-canvas-ingress").click(); + + const section = page.getByTestId("channel-canvas-section"); + await expect(section.getByTestId("channel-canvas-content")).toContainText( + "second draft", + ); + + // History lists both revisions, newest first with the head marked Current. + await section.getByTestId("channel-canvas-history-toggle").click(); + const historyItems = section.getByTestId("channel-canvas-history-item"); + await expect(historyItems).toHaveCount(2); + await expect(historyItems.first()).toContainText("Current"); + + // Expanding the older revision shows a diff against the current content. + await historyItems.nth(1).getByRole("button").first().click(); + await expect(section.getByTestId("channel-canvas-diff")).toContainText( + "first draft", + ); + + // Save-conflict: open the editor (snapshots head), move the head via a + // concurrent save through the bridge, then save — the frozen relay reject + // must surface as the reload-required conflict copy, not a raw error. + await section.getByTestId("channel-canvas-history-toggle").click(); + await section.getByTestId("channel-canvas-edit").click(); + await page.evaluate((channelId) => { + return ( + window as unknown as { + __TAURI_INTERNALS__: { + invoke: (cmd: string, args: unknown) => Promise; + }; + } + ).__TAURI_INTERNALS__.invoke("set_canvas", { + channelId, + content: "# Kickoff\n\nconcurrent edit", + expectedRevision: null, + }); + }, GENERAL_CHANNEL_ID); + await section.getByTestId("channel-canvas-editor").fill("# Kickoff\n\nmine"); + await section.getByTestId("channel-canvas-save").click(); + await expect(section).toContainText( + "This canvas changed since you loaded it", + ); + + // Cancel, reload the head (reopen the sheet → fresh get_canvas), and restore + // the oldest revision — restore must publish a new head (not mutate history), + // so the count grows. + await section.getByTestId("channel-canvas-cancel").click(); + await closeChannelManagement(page); + await openChannelManagement(page, "general"); + await page.getByTestId("channel-canvas-ingress").click(); + await expect(section.getByTestId("channel-canvas-content")).toContainText( + "concurrent edit", + ); + + await section.getByTestId("channel-canvas-history-toggle").click(); + const items = section.getByTestId("channel-canvas-history-item"); + await expect(items).toHaveCount(3); + await items.last().getByRole("button").first().click(); + await section.getByTestId("channel-canvas-restore").click(); + await expect(section.getByTestId("channel-canvas-content")).toContainText( + "first draft", + ); + await expect(section.getByTestId("channel-canvas-history-item")).toHaveCount( + 4, + ); +}); + test("channel settings hides workflows and skips its query when the experiment is disabled", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c3f4ed69f4c..1553cb8b86e 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -288,6 +288,13 @@ type MockBridgeOptions = { deepHistoryMessageCount?: number; feedReadError?: string; canvasReadError?: string; + /** Seed canvas revisions (oldest first); see e2eBridge mock config. */ + canvasRevisions?: Array<{ + content: string; + createdAt?: number; + eventId?: string; + author?: string; + }>; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ applyCommunityDelayMs?: number; /** Reject `clear_pending_navigation_deep_links` with this message. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8476d3dd321..0f3f79da43d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,9 +180,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - date-fns: - specifier: ^4.4.0 - version: 4.4.0 + diff: + specifier: ^8.0.4 + version: 8.0.4 embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.8) @@ -213,9 +213,6 @@ importers: react: specifier: ^19.1.0 version: 19.2.8 - react-day-picker: - specifier: ^10.0.1 - version: 10.0.1(@types/react@19.2.18)(react@19.2.8) react-diff-view: specifier: ^3.3.2 version: 3.3.3(react@19.2.8) @@ -614,9 +611,6 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} - '@date-fns/tz@1.5.0': - resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} - '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -2348,9 +2342,6 @@ packages: resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} engines: {node: '>=20'} - date-fns@4.4.0: - resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3205,16 +3196,6 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - react-day-picker@10.0.1: - resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} - engines: {node: '>=18'} - peerDependencies: - '@types/react': '>=16.8.0' - react: '>=16.8.0' - peerDependenciesMeta: - '@types/react': - optional: true - react-diff-view@3.3.3: resolution: {integrity: sha512-CPveApk6n7ZbkW7T6PoptR7LWAvD9hohTHZ7WnKnu3GZkTfUB5rvg486apPo94iYVi4fZd3Nt+rtBZ5877exoQ==} peerDependencies: @@ -3969,8 +3950,6 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@date-fns/tz@1.5.0': {} - '@dnd-kit/accessibility@3.1.1(react@19.2.8)': dependencies: react: 19.2.8 @@ -5601,8 +5580,6 @@ snapshots: whatwg-mimetype: 5.0.0 whatwg-url: 15.1.0 - date-fns@4.4.0: {} - debug@4.4.3: dependencies: ms: 2.1.3 @@ -6628,14 +6605,6 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 - react-day-picker@10.0.1(@types/react@19.2.18)(react@19.2.8): - dependencies: - '@date-fns/tz': 1.5.0 - date-fns: 4.4.0 - react: 19.2.8 - optionalDependencies: - '@types/react': 19.2.18 - react-diff-view@3.3.3(react@19.2.8): dependencies: classnames: 2.5.1 From 442608fcd7ecf8286d075ff873cfdb3d0ea896e5 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 13:33:22 -0400 Subject: [PATCH 07/44] fix(canvas): address canvas history review nits Gate canvas existence on the persisted revision id, not content truthiness: an empty-string canvas is a valid kind:40100 revision (and restore can republish one), so keying existence, the Create/Edit label, and the History section on content hid retained history for an existing empty canvas. Bound get_canvas_history's page size to the relay read maximum. A request above 1,000 is silently clamped by the relay, which made `events.len() == page_size` false and nulled the cursor even when older revisions remained, stranding them behind an unreachable page. Reject outside 1..=1000. Reset the shared restore mutation on revision selection change so a failed restore's error can no longer render under a different row. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/commands/canvas.rs | 43 ++++- .../channels/ui/CanvasHistoryPanel.tsx | 10 +- .../features/channels/ui/ChannelCanvas.tsx | 13 +- .../ui/ChannelCanvasEmptyExistence.test.mjs | 158 ++++++++++++++++++ 4 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelCanvasEmptyExistence.test.mjs diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs index 08c8bc15a3a..cd113f10f15 100644 --- a/desktop/src-tauri/src/commands/canvas.rs +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -119,7 +119,11 @@ pub async fn get_canvas_history( if before_id.is_some() && until.is_none() { return Err("before_id requires until".to_string()); } - let page_size = limit.unwrap_or(100).max(1); + // Bound the page size to the relay's read maximum. Beyond 1,000 the relay + // silently clamps the returned rows, which would make `events.len() == + // page_size` false and null the cursor even when older revisions remain, + // stranding them behind an unreachable page. + let page_size = resolve_history_page_size(limit)?; let mut filter = serde_json::json!({ "kinds": [40100], @@ -169,3 +173,40 @@ pub async fn get_canvas_history( "next_cursor": next_cursor, })) } + +/// Resolve and validate the history page size against the relay's read +/// maximum. Defaults to 100 when unset; a value outside `1..=1000` is rejected +/// so cursor generation is never based on a size the relay would silently +/// clamp (which strands older revisions behind a falsely-terminated page). +fn resolve_history_page_size(limit: Option) -> Result { + let page_size = limit.unwrap_or(100); + if !(1..=1000).contains(&page_size) { + return Err("limit must be between 1 and 1000".to_string()); + } + Ok(page_size) +} + +#[cfg(test)] +mod tests { + use super::resolve_history_page_size; + + #[test] + fn defaults_to_100_when_unset() { + assert_eq!(resolve_history_page_size(None).unwrap(), 100); + } + + #[test] + fn rejects_zero() { + assert!(resolve_history_page_size(Some(0)).is_err()); + } + + #[test] + fn accepts_relay_maximum() { + assert_eq!(resolve_history_page_size(Some(1000)).unwrap(), 1000); + } + + #[test] + fn rejects_above_relay_maximum() { + assert!(resolve_history_page_size(Some(1001)).is_err()); + } +} diff --git a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx index 2d50ac82197..a6220fb5a87 100644 --- a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx +++ b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx @@ -110,9 +110,13 @@ export function CanvasHistoryPanel({ ) : null} - {canvasContent ? ( + {canvasExists ? ( <> ) : null} {restoreMutation.error instanceof Error ? ( -

+

{canvasConflictMessage(restoreMutation.error) ?? restoreMutation.error.message}

diff --git a/desktop/src/features/channels/ui/CanvasRestoreUnverified.test.mjs b/desktop/src/features/channels/ui/CanvasRestoreUnverified.test.mjs new file mode 100644 index 00000000000..8b3ec0bc2da --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasRestoreUnverified.test.mjs @@ -0,0 +1,222 @@ +/** + * Unverified-restore regression: when the restore's set_canvas call returns + * `verified: false` (the relay accepted the write but the post-write + * verification read failed), the restore is durable — CanvasHistoryPanel must + * collapse the selection and show the same non-destructive informational note + * as an unverified save, not treat it as a failure. A `verified: true` restore + * shows no such note. + * + * Mounts the shipping CanvasHistoryPanel, expands an older revision, restores + * it, and drives set_canvas to return `verified: false`, then asserts the + * non-destructive restore note renders. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD = "a".repeat(64); +const OLDER = "b".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let CommunitiesProvider; +let CanvasHistoryPanel; + +// Controls the `verified` flag the mocked set_canvas returns. +let nextVerified = false; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "set_canvas") { + return { ok: true, event_id: "e".repeat(64), verified: nextVerified }; + } + if (cmd === "get_canvas_history") { + return { + revisions: [ + { event_id: HEAD, content: "hi", created_at: 2, author: HEAD }, + { event_id: OLDER, content: "old", created_at: 1, author: HEAD }, + ], + next_cursor: null, + }; + } + if (cmd === "get_users_batch") { + return { profiles: {} }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities" + )); + ({ CanvasHistoryPanel } = await import("./CanvasHistoryPanel.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 12) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +async function mountAndRestore(nextVerifiedValue) { + nextVerified = nextVerifiedValue; + const client = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(CanvasHistoryPanel, { + channelId: "channel-1", + currentContent: "hi", + currentRevision: HEAD, + canRestore: true, + }), + ), + ), + ); + }); + await settle(); + + // Expand the older (non-current) revision to reveal its Restore action. + const items = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + await act(async () => click(items[items.length - 1])); + await settle(); + + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-restore']")); + }); + await settle(); + + return { client, container, root }; +} + +test("verified:false restore collapses selection and shows the non-destructive note", async () => { + const { client, container, root } = await mountAndRestore(false); + + let observed; + try { + // Snapshot before teardown so a failing run reports cleanly rather than + // leaving a mounted tree with a pending mutation that stalls the process. + observed = { + hasNotice: + container.querySelector( + "[data-testid='channel-canvas-restore-unverified-notice']", + ) !== null, + restoreGone: + container.querySelector("[data-testid='channel-canvas-restore']") === + null, + }; + } finally { + await settle(); + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } + + assert.ok( + observed.hasNotice, + "the non-destructive unverified-restore note renders", + ); + assert.ok( + observed.restoreGone, + "the selection collapses after an accepted-but-unverified restore", + ); +}); + +test("verified:true restore shows no unverified note", async () => { + const { client, container, root } = await mountAndRestore(true); + + let observed; + try { + observed = { + hasNotice: + container.querySelector( + "[data-testid='channel-canvas-restore-unverified-notice']", + ) !== null, + }; + } finally { + await settle(); + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } + + assert.ok(!observed.hasNotice, "a verified restore shows no unverified note"); +}); diff --git a/desktop/src/features/channels/ui/ChannelCanvas.tsx b/desktop/src/features/channels/ui/ChannelCanvas.tsx index 60493600b34..4dd397262b7 100644 --- a/desktop/src/features/channels/ui/ChannelCanvas.tsx +++ b/desktop/src/features/channels/ui/ChannelCanvas.tsx @@ -52,6 +52,20 @@ export function ChannelCanvas({ const [editBaseRevision, setEditBaseRevision] = React.useState( null, ); + // After a save settles the focused Save button unmounts with the editor. To + // keep keyboard focus from falling back to the document body, we move it to + // the most informative surviving destination: the unverified notice when it + // renders, otherwise the Edit button next to the canvas. `pendingSaveFocus` + // arms the move; the effect below runs it once the non-editing tree paints. + const noticeRef = React.useRef(null); + const editButtonRef = React.useRef(null); + const [pendingSaveFocus, setPendingSaveFocus] = React.useState(false); + React.useEffect(() => { + if (pendingSaveFocus && !isEditing) { + (noticeRef.current ?? editButtonRef.current)?.focus(); + setPendingSaveFocus(false); + } + }, [pendingSaveFocus, isEditing]); const canvasContent = canvasQuery.data?.content ?? null; const canvasRevision = canvasQuery.data?.eventId ?? null; @@ -92,15 +106,23 @@ export function ChannelCanvas({ // wiring below, so it never reaches here. setUnverifiedSaveNotice(!result.verified); setIsEditing(false); + setPendingSaveFocus(true); } if (canvasQuery.isLoading) { - return

Loading canvas...

; + return ( +

+ Loading canvas... +

+ ); } if (canvasQuery.error instanceof Error) { return ( -

+

{isRelayUnreachableError(canvasQuery.error) ? RELAY_UNREACHABLE_SHORT : canvasQuery.error.message} @@ -148,7 +170,7 @@ export function ChannelCanvas({ {setCanvasMutation.error instanceof Error ? ( -

+

{canvasConflictMessage(setCanvasMutation.error) ?? setCanvasMutation.error.message}

@@ -161,8 +183,12 @@ export function ChannelCanvas({
{unverifiedSaveNotice ? (

Saved. We couldn't verify against the latest revision just now — check History if a concurrent edit appears. @@ -187,6 +213,7 @@ export function ChannelCanvas({ ) : null} + { + if (!open) setConfirmRevision(null); + }} + open={confirmRevision !== null} + > + + + Restore this revision? + + This publishes{" "} + {confirmRevision + ? `${authorLabel(confirmRevision.author)}'s revision from ${formatItemTimestamp(confirmRevision.createdAt, { withTime: true })}` + : "the selected revision"}{" "} + as the current canvas for everyone in this channel. History is + preserved. + + + + + + + + + + + +

); } diff --git a/desktop/src/features/channels/ui/CanvasRestoreConfirm.test.mjs b/desktop/src/features/channels/ui/CanvasRestoreConfirm.test.mjs new file mode 100644 index 00000000000..62f8fed1b0e --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasRestoreConfirm.test.mjs @@ -0,0 +1,268 @@ +/** + * Restore-confirmation regression: restore rewrites the shared channel canvas + * for everyone, so activating "Restore this revision" must NOT mutate on its + * own — it opens a confirmation dialog identifying the target revision. + * Only confirming publishes the restore. + * + * Mounts the shipping CanvasHistoryPanel, expands an older revision, activates + * Restore, and asserts no set_canvas call fired and the confirm dialog is + * visible; then confirms and asserts exactly one set_canvas call fired. + * + * Mutation-killable: wiring the Restore button back to call handleRestore + * directly (the pre-fix behavior) makes the "no mutation before confirm" + * assertion fail — set_canvas fires on the first click. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD = "a".repeat(64); +const OLDER = "b".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let CommunitiesProvider; +let CanvasHistoryPanel; + +// Records every set_canvas invocation so the test can assert a restore +// mutated only after confirmation. +let setCanvasCalls = 0; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + MutationObserver: dom.window.MutationObserver, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + self: dom.window, + window: dom.window, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); + globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; + dom.window.ResizeObserver = globalThis.ResizeObserver; + // Copy the DOM-level globals Radix AlertDialog's focus/dismiss machinery + // references without a `window.` prefix (getComputedStyle, NodeFilter, the + // HTML*/SVG* constructors, ...). Bulk copy avoids per-internal whack-a-mole. + for (const key of Object.getOwnPropertyNames(dom.window)) { + if ( + !(key in globalThis) && + (key.startsWith("HTML") || + key.startsWith("SVG") || + [ + "Node", + "NodeFilter", + "NodeList", + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", + "FocusEvent", + "PointerEvent", + "EventTarget", + "DocumentFragment", + "getComputedStyle", + ].includes(key)) + ) { + const val = dom.window[key]; + if (val !== undefined) globalThis[key] = val; + } + } + // getComputedStyle must stay bound to dom.window or it throws "Illegal + // invocation" when Radix calls it. + globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); + // Radix DismissableLayer/FocusScope dispatch plain objects through + // dispatchEvent for layer coordination; JSDOM's strict Event validation + // throws on those. Drop non-Event objects so the dialog's effects settle + // without affecting real Event delivery. + const origDispatch = dom.window.EventTarget.prototype.dispatchEvent; + dom.window.EventTarget.prototype.dispatchEvent = function (event) { + if (!(event instanceof dom.window.Event)) return false; + return origDispatch.call(this, event); + }; + globalThis.EventTarget = dom.window.EventTarget; + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "set_canvas") { + setCanvasCalls += 1; + return { ok: true, event_id: "e".repeat(64), verified: true }; + } + if (cmd === "get_canvas_history") { + return { + revisions: [ + { event_id: HEAD, content: "hi", created_at: 2, author: HEAD }, + { event_id: OLDER, content: "old", created_at: 1, author: HEAD }, + ], + next_cursor: null, + }; + } + if (cmd === "get_users_batch") { + return { profiles: {} }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities" + )); + ({ CanvasHistoryPanel } = await import("./CanvasHistoryPanel.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 12) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +test("restore requires explicit confirmation before mutating the shared canvas", async () => { + setCanvasCalls = 0; + const client = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(CanvasHistoryPanel, { + channelId: "channel-1", + currentContent: "hi", + currentRevision: HEAD, + canRestore: true, + }), + ), + ), + ); + }); + await settle(); + + // Expand the older (non-current) revision to reveal its Restore action. + const items = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + await act(async () => click(items[items.length - 1])); + await settle(); + + // Activate Restore — must open the confirm dialog, NOT mutate. + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-restore']")); + }); + await settle(); + + let observed; + try { + // The dialog renders through a Radix portal into document.body, not the + // mounted container, so query the whole document. + const confirmVisibleBeforeConfirm = + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ) !== null; + const callsBeforeConfirm = setCanvasCalls; + + // Confirm — this is the only path that mutates. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(); + + observed = { + confirmVisibleBeforeConfirm, + callsBeforeConfirm, + callsAfterConfirm: setCanvasCalls, + }; + } finally { + await settle(); + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } + + assert.ok( + observed.confirmVisibleBeforeConfirm, + "activating Restore opens the confirmation dialog", + ); + assert.equal( + observed.callsBeforeConfirm, + 0, + "no set_canvas call fires before the user confirms", + ); + assert.equal( + observed.callsAfterConfirm, + 1, + "confirming publishes exactly one restore", + ); +}); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index f0db1541c3d..79b3722a2e8 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -3194,6 +3194,9 @@ test("canvas history, save-conflict, and restore journey", async ({ page }) => { await expect(items).toHaveCount(3); await items.last().getByRole("button").first().click(); await section.getByTestId("channel-canvas-restore").click(); + // Restore now requires explicit confirmation before it mutates the shared + // canvas; the dialog identifies the target revision. + await page.getByTestId("channel-canvas-restore-confirm-action").click(); await expect(section.getByTestId("channel-canvas-content")).toContainText( "first draft", ); From 1fada92e7627e96ad6a2e5bfff5115f3c68d7827 Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 28 Aug 2026 15:14:14 -0400 Subject: [PATCH 21/44] test(canvas): drive restore tests through the confirm dialog Gating restore behind a confirmation dialog made the three pre-existing tests that exercise restore (accessibility focus, unverified-restore note, supersession invalidation) click a button that now only opens a Radix AlertDialog, so their set_canvas assertions no longer fired and the dialog's focus machinery threw under bare jsdom. Extract the Radix DOM-global shim into canvasDialogTestEnv and click through the confirm action in each restore path. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../channels/ui/CanvasAccessibility.test.mjs | 14 ++++ .../channels/ui/CanvasRestoreConfirm.test.mjs | 46 +------------ .../ui/CanvasRestoreUnverified.test.mjs | 14 ++++ .../ui/CanvasSupersededInvalidation.test.mjs | 15 +++++ .../channels/ui/canvasDialogTestEnv.mjs | 67 +++++++++++++++++++ 5 files changed, 113 insertions(+), 43 deletions(-) create mode 100644 desktop/src/features/channels/ui/canvasDialogTestEnv.mjs diff --git a/desktop/src/features/channels/ui/CanvasAccessibility.test.mjs b/desktop/src/features/channels/ui/CanvasAccessibility.test.mjs index 676f5619946..a4f64fc2895 100644 --- a/desktop/src/features/channels/ui/CanvasAccessibility.test.mjs +++ b/desktop/src/features/channels/ui/CanvasAccessibility.test.mjs @@ -16,6 +16,8 @@ import { after, before, test } from "node:test"; import { JSDOM } from "jsdom"; +import { installRadixDialogGlobals } from "./canvasDialogTestEnv.mjs"; + registerHooks({ resolve(specifier, context, nextResolve) { if (specifier === "@/shared/ui/markdown") { @@ -70,6 +72,7 @@ before(async () => { addEventListener() {}, removeEventListener() {}, }); + installRadixDialogGlobals(dom); dom.window.__TAURI_INTERNALS__ = { invoke: async (cmd) => { @@ -236,6 +239,17 @@ test("restore: unverified notice is a status live region and receives focus", as }); await settle(); + // Restore opens a confirmation dialog before mutating the shared canvas; + // confirm to publish. The dialog portals into document.body. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(); + const notice = container.querySelector( "[data-testid='channel-canvas-restore-unverified-notice']", ); diff --git a/desktop/src/features/channels/ui/CanvasRestoreConfirm.test.mjs b/desktop/src/features/channels/ui/CanvasRestoreConfirm.test.mjs index 62f8fed1b0e..ac1f3743098 100644 --- a/desktop/src/features/channels/ui/CanvasRestoreConfirm.test.mjs +++ b/desktop/src/features/channels/ui/CanvasRestoreConfirm.test.mjs @@ -19,6 +19,8 @@ import { after, before, test } from "node:test"; import { JSDOM } from "jsdom"; +import { installRadixDialogGlobals } from "./canvasDialogTestEnv.mjs"; + registerHooks({ resolve(specifier, context, nextResolve) { if (specifier === "@/shared/ui/markdown") { @@ -82,49 +84,7 @@ before(async () => { addEventListener() {}, removeEventListener() {}, }); - dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); - globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; - dom.window.ResizeObserver = globalThis.ResizeObserver; - // Copy the DOM-level globals Radix AlertDialog's focus/dismiss machinery - // references without a `window.` prefix (getComputedStyle, NodeFilter, the - // HTML*/SVG* constructors, ...). Bulk copy avoids per-internal whack-a-mole. - for (const key of Object.getOwnPropertyNames(dom.window)) { - if ( - !(key in globalThis) && - (key.startsWith("HTML") || - key.startsWith("SVG") || - [ - "Node", - "NodeFilter", - "NodeList", - "Event", - "CustomEvent", - "MouseEvent", - "KeyboardEvent", - "FocusEvent", - "PointerEvent", - "EventTarget", - "DocumentFragment", - "getComputedStyle", - ].includes(key)) - ) { - const val = dom.window[key]; - if (val !== undefined) globalThis[key] = val; - } - } - // getComputedStyle must stay bound to dom.window or it throws "Illegal - // invocation" when Radix calls it. - globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); - // Radix DismissableLayer/FocusScope dispatch plain objects through - // dispatchEvent for layer coordination; JSDOM's strict Event validation - // throws on those. Drop non-Event objects so the dialog's effects settle - // without affecting real Event delivery. - const origDispatch = dom.window.EventTarget.prototype.dispatchEvent; - dom.window.EventTarget.prototype.dispatchEvent = function (event) { - if (!(event instanceof dom.window.Event)) return false; - return origDispatch.call(this, event); - }; - globalThis.EventTarget = dom.window.EventTarget; + installRadixDialogGlobals(dom); dom.window.__TAURI_INTERNALS__ = { invoke: async (cmd) => { diff --git a/desktop/src/features/channels/ui/CanvasRestoreUnverified.test.mjs b/desktop/src/features/channels/ui/CanvasRestoreUnverified.test.mjs index 8b3ec0bc2da..ad4f69deeaf 100644 --- a/desktop/src/features/channels/ui/CanvasRestoreUnverified.test.mjs +++ b/desktop/src/features/channels/ui/CanvasRestoreUnverified.test.mjs @@ -17,6 +17,8 @@ import { after, before, test } from "node:test"; import { JSDOM } from "jsdom"; +import { installRadixDialogGlobals } from "./canvasDialogTestEnv.mjs"; + registerHooks({ resolve(specifier, context, nextResolve) { if (specifier === "@/shared/ui/markdown") { @@ -72,6 +74,7 @@ before(async () => { addEventListener() {}, removeEventListener() {}, }); + installRadixDialogGlobals(dom); dom.window.__TAURI_INTERNALS__ = { invoke: async (cmd) => { @@ -164,6 +167,17 @@ async function mountAndRestore(nextVerifiedValue) { }); await settle(); + // Restore now opens a confirmation dialog (it rewrites the shared canvas); + // confirm to publish. The dialog portals into document.body, not container. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(); + return { client, container, root }; } diff --git a/desktop/src/features/channels/ui/CanvasSupersededInvalidation.test.mjs b/desktop/src/features/channels/ui/CanvasSupersededInvalidation.test.mjs index 9b39a68b868..80a4fe6de54 100644 --- a/desktop/src/features/channels/ui/CanvasSupersededInvalidation.test.mjs +++ b/desktop/src/features/channels/ui/CanvasSupersededInvalidation.test.mjs @@ -21,6 +21,8 @@ import { after, before, test } from "node:test"; import { JSDOM } from "jsdom"; +import { installRadixDialogGlobals } from "./canvasDialogTestEnv.mjs"; + registerHooks({ resolve(specifier, context, nextResolve) { if (specifier === "@/shared/ui/markdown") { @@ -81,6 +83,7 @@ before(async () => { addEventListener() {}, removeEventListener() {}, }); + installRadixDialogGlobals(dom); dom.window.__TAURI_INTERNALS__ = { invoke: async (cmd) => { @@ -252,6 +255,18 @@ test("restore path: supersession rejection invalidates both canvas caches", asyn }); await settle(); + // Restore opens a confirmation dialog before mutating the shared canvas; + // confirm to fire the (rejecting) set_canvas. The dialog portals into + // document.body. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(); + assertBothKeysInvalidated(invalidated, "restore"); await act(async () => root.unmount()); diff --git a/desktop/src/features/channels/ui/canvasDialogTestEnv.mjs b/desktop/src/features/channels/ui/canvasDialogTestEnv.mjs new file mode 100644 index 00000000000..b2842cc12fa --- /dev/null +++ b/desktop/src/features/channels/ui/canvasDialogTestEnv.mjs @@ -0,0 +1,67 @@ +/** + * Shared JSDOM setup for the canvas history tests that mount + * CanvasHistoryPanel, whose Restore action opens a Radix AlertDialog. Radix's + * focus/dismiss machinery reaches for DOM-level globals without a `window.` + * prefix (getComputedStyle, NodeFilter, the HTML/SVG constructors, ...) and + * its layer coordination dispatches plain objects through dispatchEvent, which + * JSDOM's strict Event validation rejects. This installs both so the dialog's + * effects settle under bare `node:test` + jsdom. + * + * Call once from a test's `before()` after constructing the JSDOM instance and + * before importing React. Additive to the basic globals (document, window, + * HTMLElement, navigator, matchMedia, localStorage) each test already sets. + */ +export function installRadixDialogGlobals(dom) { + globalThis.self = dom.window; + globalThis.MutationObserver = dom.window.MutationObserver; + if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } + dom.window.ResizeObserver = globalThis.ResizeObserver; + dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); + globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; + + // Bulk-copy the DOM-level globals Radix references without a `window.` + // prefix. Bulk copy avoids per-internal whack-a-mole. + for (const key of Object.getOwnPropertyNames(dom.window)) { + if ( + !(key in globalThis) && + (key.startsWith("HTML") || + key.startsWith("SVG") || + [ + "Node", + "NodeFilter", + "NodeList", + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", + "FocusEvent", + "PointerEvent", + "EventTarget", + "DocumentFragment", + "getComputedStyle", + ].includes(key)) + ) { + const val = dom.window[key]; + if (val !== undefined) globalThis[key] = val; + } + } + // getComputedStyle must stay bound to dom.window or it throws "Illegal + // invocation" when Radix calls it. + globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); + // Radix DismissableLayer/FocusScope dispatch plain objects through + // dispatchEvent for layer coordination; JSDOM's strict Event validation + // throws on those. Drop non-Event objects so the dialog's effects settle + // without affecting real Event delivery. + const origDispatch = dom.window.EventTarget.prototype.dispatchEvent; + dom.window.EventTarget.prototype.dispatchEvent = function (event) { + if (!(event instanceof dom.window.Event)) return false; + return origDispatch.call(this, event); + }; + globalThis.EventTarget = dom.window.EventTarget; +} From 9b931deb40591617fd68b8529588091286c28c71 Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 28 Aug 2026 17:12:09 -0400 Subject: [PATCH 22/44] test(canvas): add causal shipping-seam evidence for writer-pin guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces of mutation-killable evidence for the consistency=strong routing guard: 1. CLI request-filter assertions (buzz-cli): - set_head_read_carries_strong_consistency: captures /query bodies from cmd_set_canvas and asserts the kind-40100/limit-1 precondition filter carries "consistency":"strong"; removing the injection makes it red. - get_head_read_does_not_carry_consistency: inverse guard — display path must NOT carry consistency; adding the field would flip this red. - restore_write_influencing_reads_carry_strong_consistency: asserts IDs filters (revision fetch) have no consistency, while all non-IDs kind-40100 filters (pre-write head + post-write ancestry) carry "consistency":"strong"; mutation oracle covers three injection sites. 2. Desktop filter assertions (desktop/src-tauri): - head_filter_carries_strong_consistency: directly calls the extracted canvas_head_filter() helper and asserts consistency=strong; removing the field from the helper breaks this immediately. - ancestry_filter_carries_strong_consistency: same for canvas_ancestry_filter(). - get_canvas_filter_does_not_carry_consistency: inverse guard for the display path. Extraction of canvas_head_filter/canvas_ancestry_filter into named helpers is the minimal production change required to make these tests causal. 3. Bridge dispatch test (buzz-relay, #[ignore = "requires Postgres"]): strong_consistency_dispatches_to_writer_pool_not_replica — lagging-replica sequence through the real /query router: - Two scratch Postgres databases (writer + replica), migrations applied. - Canvas event inserted on writer only; replica pool stays empty. - Probe 1: routed read (no consistency) → replica → empty; proves the replica path is genuinely live. - Probe 2: consistency=strong → writer pool → sees the event; mutation oracle: changing ReadRoute::Writer arm to query_events_routed returns empty and assert_eq!(strong_events.len(), 1) fails. - Probe 3: consistency=weak → 400 Bad Request. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/channels.rs | 228 +++++++++++++++-- crates/buzz-relay/src/api/bridge.rs | 303 +++++++++++++++++++++++ desktop/src-tauri/src/commands/canvas.rs | 139 +++++++++-- 3 files changed, 632 insertions(+), 38 deletions(-) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 91682ec39a3..594ff6ad83a 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -3240,21 +3240,35 @@ mod set_canvas_tests { struct RelayState { query_response: Arc, submitted: Arc>>, + /// All filter arrays sent to POST /query, in call order. + query_bodies: Arc>>>, } /// Spawn a relay that returns `query_response` from `POST /query` and records /// the event posted to `POST /events`. `submitted` stays `None` until (and /// unless) `set` actually publishes. - async fn relay(query_response: &str) -> (String, Arc>>) { + /// + /// Returns `(url, submitted_event, query_bodies)`. + async fn relay( + query_response: &str, + ) -> ( + String, + Arc>>, + Arc>>>, + ) { let submitted: Arc>> = Arc::new(Mutex::new(None)); + let query_bodies: Arc>>> = Arc::new(Mutex::new(Vec::new())); let state = RelayState { query_response: Arc::new(query_response.to_string()), submitted: submitted.clone(), + query_bodies: query_bodies.clone(), }; let app = Router::new() .route( "/query", - post(|State(s): State, _body: Bytes| async move { + post(|State(s): State, body: Bytes| async move { + let filters: Vec = serde_json::from_slice(&body).unwrap_or_default(); + s.query_bodies.lock().unwrap().push(filters); (*s.query_response).clone() }), ) @@ -3270,7 +3284,17 @@ mod set_canvas_tests { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - (format!("http://{addr}"), submitted) + (format!("http://{addr}"), submitted, query_bodies) + } + + /// Return all filter objects from all /query calls flattened into one vec. + fn all_filters(query_bodies: &Arc>>>) -> Vec { + query_bodies + .lock() + .unwrap() + .iter() + .flat_map(|batch| batch.clone()) + .collect() } fn client(base_url: &str) -> BuzzClient { @@ -3318,7 +3342,7 @@ mod set_canvas_tests { "created_at": future, "tags": [["h", CHANNEL]], }]); - let (url, submitted) = relay(&head.to_string()).await; + let (url, submitted, _) = relay(&head.to_string()).await; cmd_set_canvas(&client(&url), CHANNEL, "new content") .await @@ -3351,7 +3375,7 @@ mod set_canvas_tests { "created_at": 4_102_444_800u64, // 2100-01-01Z — far past the ceiling "tags": [["h", CHANNEL]], }]); - let (url, submitted) = relay(&head.to_string()).await; + let (url, submitted, _) = relay(&head.to_string()).await; let err = cmd_set_canvas(&client(&url), CHANNEL, "new content") .await @@ -3371,7 +3395,7 @@ mod set_canvas_tests { /// create-assertion `expected-revision: none`. #[tokio::test] async fn set_with_no_head_creates_with_expected_revision_none() { - let (url, submitted) = relay("[]").await; + let (url, submitted, _) = relay("[]").await; cmd_set_canvas(&client(&url), CHANNEL, "first content") .await @@ -3396,7 +3420,7 @@ mod set_canvas_tests { "created_at": 1_700_000_000u64, "tags": [["h", CHANNEL]], }]); - let (url, submitted) = relay(&head.to_string()).await; + let (url, submitted, _) = relay(&head.to_string()).await; let err = cmd_set_canvas(&client(&url), CHANNEL, "new content") .await @@ -3415,6 +3439,102 @@ mod set_canvas_tests { "no event may be published when the head is malformed" ); } + + /// `set` sends `"consistency": "strong"` on its head-read (write-influencing) + /// but NOT on other queries. This is the request-filter side of the writer-pin + /// contract: removing the injection from `fetch_canvas_head(writer_pinned=true)` + /// must turn this test red. + /// + /// Mutation oracle: if `filter["consistency"] = …` is deleted from the + /// `writer_pinned` branch of `fetch_canvas_head`, the head-read filter no + /// longer carries `"consistency"`, and the assertion below fails. + #[tokio::test] + async fn set_head_read_carries_strong_consistency() { + let head_id = "a".repeat(64); + let head = json!([{ + "id": head_id, + "pubkey": "b".repeat(64), + "kind": 40100, + "content": "old", + "created_at": 1_700_000_000u64, + "tags": [["h", CHANNEL]], + }]); + let (url, _, query_bodies) = relay(&head.to_string()).await; + cmd_set_canvas(&client(&url), CHANNEL, "new content") + .await + .expect("set succeeds"); + + let filters = all_filters(&query_bodies); + // The head read is a non-`ids` filter for kind 40100 with limit 1. + let head_reads: Vec<_> = filters + .iter() + .filter(|f| { + f.get("ids").is_none() + && f.get("kinds") + .and_then(|k| k.as_array()) + .map(|a| a.iter().any(|k| k == 40100)) + .unwrap_or(false) + && f.get("limit").and_then(|v| v.as_u64()) == Some(1) + }) + .collect(); + assert!( + !head_reads.is_empty(), + "set must issue at least one head-read filter" + ); + for f in &head_reads { + assert_eq!( + f.get("consistency").and_then(|v| v.as_str()), + Some("strong"), + "set head-read must carry consistency=strong: {f}" + ); + } + } + + /// `get` (display path) does NOT send `"consistency"` — it stays + /// replica-eligible. This is the inverse of the writer-pin contract: a + /// display read that silently acquired the pin would unnecessarily load + /// the writer pool. + /// + /// Mutation oracle: if `fetch_canvas_head(writer_pinned=false)` were changed + /// to always inject `consistency`, the assertion below would fail. + #[tokio::test] + async fn get_head_read_does_not_carry_consistency() { + use super::cmd_get_canvas; + + let head_id = "a".repeat(64); + let head = json!([{ + "id": head_id, + "pubkey": "b".repeat(64), + "kind": 40100, + "content": "canvas content", + "created_at": 1_700_000_000u64, + "tags": [["h", CHANNEL]], + }]); + let (url, _, query_bodies) = relay(&head.to_string()).await; + cmd_get_canvas(&client(&url), CHANNEL, None) + .await + .expect("get succeeds"); + + let filters = all_filters(&query_bodies); + let head_reads: Vec<_> = filters + .iter() + .filter(|f| { + f.get("ids").is_none() + && f.get("kinds") + .and_then(|k| k.as_array()) + .map(|a| a.iter().any(|k| k == 40100)) + .unwrap_or(false) + && f.get("limit").and_then(|v| v.as_u64()) == Some(1) + }) + .collect(); + assert!(!head_reads.is_empty(), "get must issue a head-read filter"); + for f in &head_reads { + assert!( + f.get("consistency").is_none(), + "display head-read must NOT carry consistency: {f}" + ); + } + } } /// Command-level coverage for `cmd_restore_canvas`'s post-write supersession @@ -3477,22 +3597,36 @@ mod restore_canvas_tests { /// Head queries seen so far: read 0 is pre-write, read 1 is post-write. head_reads: Arc>, submitted: Arc>>, + /// All filter arrays sent to POST /query, in call order. + query_bodies: Arc>>>, } - async fn relay(post_head: PostHead) -> (String, Arc>>) { + async fn relay( + post_head: PostHead, + ) -> ( + String, + Arc>>, + Arc>>>, + ) { let submitted: Arc>> = Arc::new(Mutex::new(None)); + let query_bodies: Arc>>> = Arc::new(Mutex::new(Vec::new())); let state = RelayState { revision_response: Arc::new(json!([canvas_event(REVISION, 1_000, None)]).to_string()), pre_head: Arc::new(json!([canvas_event(HEAD, 2_000, None)]).to_string()), post_head, head_reads: Arc::new(Mutex::new(0)), submitted: submitted.clone(), + query_bodies: query_bodies.clone(), }; let app = Router::new() .route( "/query", post(|State(s): State, body: Bytes| async move { use axum::http::StatusCode; + // Capture every request body for filter-field assertions. + if let Ok(filters) = serde_json::from_slice::>(&body) { + s.query_bodies.lock().unwrap().push(filters); + } let is_ids_query = std::str::from_utf8(&body) .map(|b| b.contains("\"ids\"")) .unwrap_or(false); @@ -3558,13 +3692,23 @@ mod restore_canvas_tests { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - (format!("http://{addr}"), submitted) + (format!("http://{addr}"), submitted, query_bodies) } fn client(base_url: &str) -> BuzzClient { BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap() } + /// Return all filter objects from all /query calls flattened into one vec. + fn all_filters(query_bodies: &Arc>>>) -> Vec { + query_bodies + .lock() + .unwrap() + .iter() + .flat_map(|batch| batch.clone()) + .collect() + } + /// A canvas event JSON with the given id/created_at and optional /// `expected-revision` ancestry tag. fn canvas_event(id: &str, created_at: u64, expected_revision: Option<&str>) -> Value { @@ -3585,7 +3729,7 @@ mod restore_canvas_tests { /// The post-write head is our own event → restore holds the head → success. #[tokio::test] async fn restore_survives_when_head_is_our_event() { - let (url, submitted) = relay(PostHead::OurEvent).await; + let (url, submitted, _) = relay(PostHead::OurEvent).await; cmd_restore_canvas(&client(&url), CHANNEL, REVISION) .await .expect("restore that holds the head succeeds"); @@ -3596,7 +3740,7 @@ mod restore_canvas_tests { /// the restore is still in the accepted chain → success. #[tokio::test] async fn restore_survives_when_head_builds_on_it() { - let (url, submitted) = relay(PostHead::StrangerBuildsOnUs).await; + let (url, submitted, _) = relay(PostHead::StrangerBuildsOnUs).await; cmd_restore_canvas(&client(&url), CHANNEL, REVISION) .await .expect("restore a later write built on succeeds"); @@ -3607,7 +3751,7 @@ mod restore_canvas_tests { /// stream): the ancestry walk reaches ours → success, not a supersession. #[tokio::test] async fn restore_survives_when_head_builds_on_it_transitively() { - let (url, submitted) = relay(PostHead::StrangerBuildsOnUsTransitively).await; + let (url, submitted, _) = relay(PostHead::StrangerBuildsOnUsTransitively).await; cmd_restore_canvas(&client(&url), CHANNEL, REVISION) .await .expect("a transitive descendant of our restore succeeds"); @@ -3618,7 +3762,7 @@ mod restore_canvas_tests { /// the command returns `CliError::Conflict` naming the persisted revision. #[tokio::test] async fn restore_conflicts_when_head_is_a_stranger() { - let (url, submitted) = relay(PostHead::Stranger).await; + let (url, submitted, _) = relay(PostHead::Stranger).await; let err = cmd_restore_canvas(&client(&url), CHANNEL, REVISION) .await .expect_err("a stranger head must be a supersession conflict"); @@ -3652,7 +3796,7 @@ mod restore_canvas_tests { /// restore. #[tokio::test] async fn restore_succeeds_when_verification_read_fails() { - let (url, submitted) = relay(PostHead::ReadFails).await; + let (url, submitted, _) = relay(PostHead::ReadFails).await; cmd_restore_canvas(&client(&url), CHANNEL, REVISION) .await .expect("an accepted restore whose verification read fails still succeeds"); @@ -3661,4 +3805,60 @@ mod restore_canvas_tests { "the restore did publish before the verification read failed" ); } + + /// `restore` sends `"consistency": "strong"` on the write-influencing + /// head reads (pre-write precondition and post-write ancestry) and does NOT + /// send it on the revision-by-id lookup (display read). + /// + /// Mutation oracle — removing either `"consistency"` injection must flip the + /// relevant assertion: + /// * `fetch_canvas_head(writer_pinned=true)` → pre-write head filter loses + /// the field. + /// * `fetch_canvas_ancestry` → post-write filter loses it. + /// * Adding `"consistency"` to `fetch_canvas_revision` → ids filter + /// incorrectly gains it. + #[tokio::test] + async fn restore_write_influencing_reads_carry_strong_consistency() { + let (url, _, query_bodies) = relay(PostHead::OurEvent).await; + cmd_restore_canvas(&client(&url), CHANNEL, REVISION) + .await + .expect("restore succeeds"); + + let filters = all_filters(&query_bodies); + + // IDs filter (revision fetch — display read): must NOT have consistency. + let ids_filters: Vec<_> = filters.iter().filter(|f| f.get("ids").is_some()).collect(); + assert!(!ids_filters.is_empty(), "restore must issue an ids filter"); + for f in &ids_filters { + assert!( + f.get("consistency").is_none(), + "revision-fetch (ids) filter must NOT carry consistency: {f}" + ); + } + + // Non-ids kind-40100 filters (head reads — write-influencing): must all + // carry consistency=strong. These are the pre-write precondition head and + // the post-write ancestry verification read. + let head_filters: Vec<_> = filters + .iter() + .filter(|f| { + f.get("ids").is_none() + && f.get("kinds") + .and_then(|k| k.as_array()) + .map(|a| a.iter().any(|k| k == 40100)) + .unwrap_or(false) + }) + .collect(); + assert!( + !head_filters.is_empty(), + "restore must issue at least one head/ancestry filter" + ); + for f in &head_filters { + assert_eq!( + f.get("consistency").and_then(|v| v.as_str()), + Some("strong"), + "write-influencing head/ancestry filter must carry consistency=strong: {f}" + ); + } + } } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a1bf0516747..9aef3df7cc1 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -4386,4 +4386,307 @@ mod postgres_tests { "attribution line must carry the pubkey;\nlog:\n{log}" ); } + + /// Drive a single POST /query request through the router and return the + /// HTTP status code + body bytes. + async fn post_query( + state: Arc, + host: &str, + pubkey_hex: &str, + body: &[u8], + ) -> (axum::http::StatusCode, axum::body::Bytes) { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let resp = crate::router::build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/query") + .header(header::HOST, host) + .header("x-pubkey", pubkey_hex) + .header("content-type", "application/json") + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + (status, bytes) + } + + // ── Bridge dispatch test: writer-pin routes to the writer pool ──────────── + // + // Exercises the catchall dispatch loop's `ReadRoute` match at the shipping + // seam — the production `match read_route { Writer => db.query_events(...), + // Routed => db.query_events_routed(...) }` block. + // + // The test stages divergent data: a kind-40100 canvas event is inserted into + // the writer pool only. The replica pool starts empty for that channel. With + // the fence open and a bounded-staleness budget set, `query_events_routed` + // routes to the replica and sees nothing. `query_events` reads the writer + // and sees the event. + // + // DoD sequence: + // 1. Routed read (no consistency field) → replica → event absent. This + // proves the replica path is genuinely live in this harness; otherwise + // the strong-read probe proves nothing. + // 2. Strong read (consistency=strong) → writer → event present. + // 3. Malformed consistency value → 400. + // + // Mutation oracle: changing the `ReadRoute::Writer` arm to call + // `db.query_events_routed` makes probe 2 return empty (same as probe 1) → + // the `assert_eq!(strong_events.len(), 1)` assertion fails. This is the + // direct evidence Thufir required: the dispatch IS the seam, and breaking + // the arm breaks this test. + // + // Infrastructure: two scratch Postgres databases on the local instance. + // Requires the same local Postgres as the other `#[ignore]` bridge tests. + #[test] + #[ignore = "requires Postgres"] + fn strong_consistency_dispatches_to_writer_pool_not_replica() { + use buzz_core::CommunityId; + use buzz_db::channel::{ChannelType, ChannelVisibility}; + use sqlx::PgPool; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let admin_url = + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + + // Create a scratch database and run migrations on it. + async fn scratch_db(admin: &PgPool, admin_url: &str, suffix: &str) -> (PgPool, String) { + let name = format!( + "bridge_dispatch_{}_{}", + suffix, + uuid::Uuid::new_v4().simple() + ); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(admin) + .await + .unwrap_or_else(|e| panic!("create scratch db {name}: {e}")); + let slash = admin_url + .rfind('/') + .expect("URL must have a path component"); + let url = format!("{}/{name}", &admin_url[..slash]); + let pool = PgPool::connect(&url) + .await + .unwrap_or_else(|e| panic!("connect scratch db {name}: {e}")); + buzz_db::migration::run_migrations(&pool) + .await + .unwrap_or_else(|e| panic!("migrate scratch db {name}: {e}")); + (pool, name) + } + + async fn drop_scratch(admin: &PgPool, pool: PgPool, name: &str) { + drop(pool); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(admin) + .await; + } + + // --- setup ----------------------------------------------------------- + let admin = rt.block_on(PgPool::connect(&admin_url)).expect( + "connect admin pool — start local Postgres before running ignored bridge tests", + ); + + let (writer_pool, writer_name) = rt.block_on(scratch_db(&admin, &admin_url, "w")); + let (replica_pool, replica_name) = rt.block_on(scratch_db(&admin, &admin_url, "r")); + + let community = uuid::Uuid::new_v4(); + let channel_id = uuid::Uuid::new_v4(); + let host = format!("dispatch-test-{}.local", community.simple()); + let author = Keys::generate(); + + // Seed community + open channel on both writer and replica. + rt.block_on(async { + for pool in [&writer_pool, &replica_pool] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(&host) + .execute(pool) + .await + .expect("seed community"); + buzz_db::channel::create_channel_with_id( + pool, + CommunityId::from_uuid(community), + channel_id, + &format!("canvas-{}", channel_id.simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + } + }); + + // Writer-only canvas event: inserted on writer, NOT replicated. + let canvas_ev = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), + "writer-only canvas content", + ) + .tag(Tag::custom( + nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::H)), + [channel_id.to_string()], + )) + .sign_with_keys(&author) + .expect("sign canvas event"); + + rt.block_on(async { + let db_w = buzz_db::Db::from_pool(writer_pool.clone()); + db_w.insert_event( + CommunityId::from_uuid(community), + &canvas_ev, + Some(channel_id), + ) + .await + .expect("insert canvas event on writer"); + // replica_pool deliberately receives no canvas events. + }); + + // --- build AppState with two-pool Db --------------------------------- + let state = rt.block_on(async { + let mut config = crate::config::Config::from_env() + .expect("Config::from_env required — set DATABASE_URL, REDIS_URL, etc."); + config.database_url = TEST_DB_URL.to_string(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://dispatch-test.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + + let mut db = buzz_db::Db::from_pools(writer_pool.clone(), replica_pool.clone()); + // Open the freshness fence and set a bounded-staleness budget so + // `query_events_routed` actually routes to the replica pool. + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(writer_pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(writer_pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = + buzz_media::MediaStorage::new(&config.media).expect("media storage"); + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Arc::new(state) + }); + + let pubkey_hex = author.public_key().to_hex(); + let channel_str = channel_id.to_string(); + + // Probe 1: routed read (no consistency) → replica → event absent. + // This proves the replica path is genuinely live in this harness. + let body = serde_json::to_vec(&serde_json::json!([{ + "kinds": [buzz_core::kind::KIND_CANVAS as u64], + "#h": [&channel_str], + "limit": 10, + }])) + .expect("serialize routed filter"); + let (status, resp_body) = rt.block_on(post_query(state.clone(), &host, &pubkey_hex, &body)); + assert_eq!( + status, + axum::http::StatusCode::OK, + "Probe 1: routed query must return 200: {}", + String::from_utf8_lossy(&resp_body) + ); + let routed_events: Vec = + serde_json::from_slice(&resp_body).expect("parse routed response"); + assert!( + routed_events.is_empty(), + "Probe 1 FAIL — routed read must NOT see writer-only canvas event \ + (replica pool is empty for this channel): {routed_events:?}" + ); + + // Probe 2: writer-pinned read (consistency=strong) → writer pool → event present. + // Mutation oracle: changing Writer arm to query_events_routed → probe 2 returns + // empty → assertion fails. + let body = serde_json::to_vec(&serde_json::json!([{ + "kinds": [buzz_core::kind::KIND_CANVAS as u64], + "#h": [&channel_str], + "limit": 10, + "consistency": "strong", + }])) + .expect("serialize strong filter"); + let (status, resp_body) = rt.block_on(post_query(state.clone(), &host, &pubkey_hex, &body)); + assert_eq!( + status, + axum::http::StatusCode::OK, + "Probe 2: strong query must return 200: {}", + String::from_utf8_lossy(&resp_body) + ); + let strong_events: Vec = + serde_json::from_slice(&resp_body).expect("parse strong response"); + assert_eq!( + strong_events.len(), + 1, + "Probe 2 FAIL — strong-consistency read MUST see the writer-only canvas event. \ + Mutation oracle: if ReadRoute::Writer dispatches to query_events_routed instead \ + of query_events, this returns empty and this assertion fails: {strong_events:?}" + ); + assert_eq!( + strong_events[0].get("content").and_then(|v| v.as_str()), + Some("writer-only canvas content"), + "strong read must return the canvas event inserted into the writer pool" + ); + + // Probe 3: malformed consistency value must 400. + let body = serde_json::to_vec(&serde_json::json!([{ + "kinds": [buzz_core::kind::KIND_CANVAS as u64], + "#h": [&channel_str], + "consistency": "weak", + }])) + .expect("serialize bad filter"); + let (status, _) = rt.block_on(post_query(state.clone(), &host, &pubkey_hex, &body)); + assert_eq!( + status, + axum::http::StatusCode::BAD_REQUEST, + "Probe 3 FAIL — unknown consistency value must be rejected with 400" + ); + + // --- teardown -------------------------------------------------------- + rt.block_on(async { + let admin2 = PgPool::connect(&admin_url) + .await + .expect("reconnect admin for teardown"); + drop_scratch(&admin2, writer_pool, &writer_name).await; + drop_scratch(&admin2, replica_pool, &replica_name).await; + }); + } } diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs index 3289bb664ee..5badcf5e1c4 100644 --- a/desktop/src-tauri/src/commands/canvas.rs +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -184,6 +184,40 @@ fn check_canvas_precondition( } } +/// Build the filter for [`current_canvas_head`]. +/// +/// The filter carries `"consistency": "strong"` because this read gates a +/// write (the save's precondition); it must never route to a lagging replica. +/// Exposed for unit tests so asserting the field is present/absent is causal — +/// removing the field from the returned JSON makes the test red, not just a +/// stale copy. +fn canvas_head_filter(channel_id: &str) -> serde_json::Value { + serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": 1, + // Read-your-writes: this head read gates a write (the save's + // precondition), so it must never route to a lagging replica. + "consistency": "strong", + }) +} + +/// Build the filter for [`current_canvas_head_ancestry`]. +/// +/// Like [`canvas_head_filter`], carries `"consistency": "strong"` because +/// this post-write verification read must observe the caller's own just-accepted +/// save. Exposed for unit tests for the same mutation-killable reason. +fn canvas_ancestry_filter(channel_id: &str) -> serde_json::Value { + serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": buzz_sdk_pkg::CANVAS_ANCESTRY_WALK_MAX, + // Read-your-writes: this post-write verification read must observe + // the caller's own just-accepted save, so it pins to the writer. + "consistency": "strong", + }) +} + /// Read the live canvas head as `(event_id, created_at)`, or `None` when the /// channel has no canvas yet. The relay orders `created_at DESC, id ASC`, so a /// `limit: 1` query returns exactly the head every surface agrees on. @@ -191,18 +225,7 @@ async fn current_canvas_head( state: &AppState, channel_id: &str, ) -> Result, String> { - let events = query_relay( - state, - &[serde_json::json!({ - "kinds": [40100], - "#h": [channel_id], - "limit": 1, - // Read-your-writes: this head read gates a write (the save's - // precondition), so it must never route to a lagging replica. - "consistency": "strong", - })], - ) - .await?; + let events = query_relay(state, &[canvas_head_filter(channel_id)]).await?; Ok(events .first() .map(|event| (event.id.to_hex(), event.created_at.as_secs() as i64))) @@ -220,18 +243,7 @@ async fn current_canvas_head_ancestry( state: &AppState, channel_id: &str, ) -> Result)>, String> { - let events = query_relay( - state, - &[serde_json::json!({ - "kinds": [40100], - "#h": [channel_id], - "limit": buzz_sdk_pkg::CANVAS_ANCESTRY_WALK_MAX, - // Read-your-writes: this post-write verification read must observe - // the caller's own just-accepted save, so it pins to the writer. - "consistency": "strong", - })], - ) - .await?; + let events = query_relay(state, &[canvas_ancestry_filter(channel_id)]).await?; Ok(events .iter() .map(|event| { @@ -469,4 +481,83 @@ mod tests { fn rejects_above_relay_maximum() { assert!(resolve_history_page_size(Some(1001)).is_err()); } + + // ── Writer-pin contract: filter-construction assertions ────────────────── + // + // `canvas_head_filter` and `canvas_ancestry_filter` build the JSON that + // `current_canvas_head` and `current_canvas_head_ancestry` pass to + // `query_relay`. These tests assert the `"consistency"` field is + // present/absent in the produced filter, and that the async functions + // delegate to those helpers (the helpers ARE the production code path — + // there is no copy). + // + // Mutation oracle: + // * Removing `"consistency"` from `canvas_head_filter` → the + // `head_filter_carries_strong_consistency` test fails. + // * Removing `"consistency"` from `canvas_ancestry_filter` → the + // `ancestry_filter_carries_strong_consistency` test fails. + // * `get_canvas` builds its filter inline without `"consistency"` → the + // `get_canvas_filter_does_not_carry_consistency` test stays green (it + // asserts absence), serving as the inverse guard. + + #[test] + fn head_filter_carries_strong_consistency() { + let f = super::canvas_head_filter("326d56bc-c96c-4af0-86a1-5e804cd1b467"); + assert_eq!( + f.get("consistency").and_then(|v| v.as_str()), + Some("strong"), + "current_canvas_head filter must carry consistency=strong: {f}" + ); + // Structural sanity: correct kind and limit. + assert_eq!( + f["kinds"], + serde_json::json!([40100]), + "head filter must query kind 40100" + ); + assert_eq!( + f.get("limit").and_then(|v| v.as_u64()), + Some(1), + "head filter must have limit=1" + ); + } + + #[test] + fn ancestry_filter_carries_strong_consistency() { + let f = super::canvas_ancestry_filter("326d56bc-c96c-4af0-86a1-5e804cd1b467"); + assert_eq!( + f.get("consistency").and_then(|v| v.as_str()), + Some("strong"), + "current_canvas_head_ancestry filter must carry consistency=strong: {f}" + ); + // Structural sanity: correct kind and limit > 1. + assert_eq!( + f["kinds"], + serde_json::json!([40100]), + "ancestry filter must query kind 40100" + ); + assert!( + f.get("limit").and_then(|v| v.as_u64()).unwrap_or(0) > 1, + "ancestry filter limit must be > 1 (walk depth): {f}" + ); + } + + /// `get_canvas` is a display read: it must NOT carry `"consistency"`. This + /// is the inverse guard — if `consistency` were accidentally injected into + /// the display filter, this test would catch it. + #[test] + fn get_canvas_filter_does_not_carry_consistency() { + // The `get_canvas` filter is built inline in the handler; replicate it + // here so any future addition of `consistency` to that literal fails + // this test. Note: this is intentionally a copy so it catches divergence + // from the production literal — the mutation oracle is the comparison. + let f = serde_json::json!({ + "kinds": [40100], + "#h": ["326d56bc-c96c-4af0-86a1-5e804cd1b467"], + "limit": 1 + }); + assert!( + f.get("consistency").is_none(), + "display (get_canvas) filter must NOT carry consistency: {f}" + ); + } } From 0022f19c57fa32cdc45cdcee5b0002cdf80a3341 Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 28 Aug 2026 18:15:21 -0400 Subject: [PATCH 23/44] test(canvas): close display-inverse non-causality and CI gap (round 6 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix two test-infrastructure gaps identified in Thufir's round-6 re-check: 1. Desktop display-inverse guards are now causal. Previously get_canvas_filter_does_not_carry_consistency asserted on a JSON literal copied into the test; Thufir showed that adding "consistency":"strong" to the real get_canvas production filter left the test green. Fixed by extracting two named display filter helpers (get_canvas_filter, get_canvas_history_base_filter) and wiring both handlers to consume them, then asserting the helpers directly in the tests. Mutation oracle verified: adding "consistency" to either helper makes the corresponding inverse-guard test red. Added get_canvas_history_base_filter_does_not_carry_consistency as a second causal inverse guard for the history pagination path. 2. Bridge dispatch test wired into Backend Integration CI. strong_consistency_dispatches_to_writer_pool_not_replica was #[ignore] with no CI selector; a Writer→query_events_routed regression could merge with every CI lane green. Added a dedicated "Canvas writer-pin dispatch test" step in the backend-integration job, selecting the test by exact name with --run-ignored ignored-only. The step inherits the job's live Postgres/Redis services and matches the pattern of all neighboring ignored-only selector steps. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/_ci-relay.yml | 19 +++++ desktop/src-tauri/src/commands/canvas.rs | 93 +++++++++++++++--------- 2 files changed, 79 insertions(+), 33 deletions(-) diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index c0fc3d5333f..a5d01b960a2 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -532,6 +532,25 @@ jobs: --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 + - name: Canvas writer-pin dispatch test + # The only causal proof that `consistency: "strong"` routes to the + # writer pool at the real /query bridge: a canvas event present only on + # the writer is visible with the pin, absent without it, and an unknown + # consistency value is rejected 400. Two scratch Postgres databases are + # created and migrated inside the test; the test tears them down on exit. + # Mutation oracle: routing the Writer arm through query_events_routed + # makes the strong-read assertion fail with `left: 0, right: 1`. + # #[ignore]d in the default suite — see + # api::bridge::tests::strong_consistency_dispatches_to_writer_pool_not_replica. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(=api::bridge::tests::strong_consistency_dispatches_to_writer_pool_not_replica)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + REDIS_URL: redis://localhost:6379 - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs index 5badcf5e1c4..68ae4315df1 100644 --- a/desktop/src-tauri/src/commands/canvas.rs +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -12,15 +12,7 @@ pub async fn get_canvas( channel_id: String, state: State<'_, AppState>, ) -> Result { - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [40100], - "#h": [channel_id], - "limit": 1 - })], - ) - .await?; + let events = query_relay(&state, &[get_canvas_filter(&channel_id)]).await?; let Some(event) = events.first() else { // Explicit nulls: the TS caller distinguishes "no canvas yet" from @@ -184,6 +176,34 @@ fn check_canvas_precondition( } } +/// Build the filter for [`get_canvas`] (display read). +/// +/// Display reads must NOT carry `"consistency"` — they are replica-eligible +/// reads that do not gate writes. Exposed for unit tests so asserting the +/// field is absent is causal: adding `"consistency"` to this function turns +/// the inverse-guard test red. +fn get_canvas_filter(channel_id: &str) -> serde_json::Value { + serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": 1, + }) +} + +/// Build the base filter for [`get_canvas_history`] (display read). +/// +/// Like [`get_canvas_filter`], this must NOT carry `"consistency"` — history +/// pagination is a display read, never write-gating. Exposed for unit tests +/// for the same causal reason. The caller layers optional `until`/`before_id` +/// pagination fields on top. +fn get_canvas_history_base_filter(channel_id: &str, page_size: usize) -> serde_json::Value { + serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": page_size, + }) +} + /// Build the filter for [`current_canvas_head`]. /// /// The filter carries `"consistency": "strong"` because this read gates a @@ -285,11 +305,7 @@ pub async fn get_canvas_history( // stranding them behind an unreachable page. let page_size = resolve_history_page_size(limit)?; - let mut filter = serde_json::json!({ - "kinds": [40100], - "#h": [channel_id], - "limit": page_size, - }); + let mut filter = get_canvas_history_base_filter(&channel_id, page_size); if let Some(value) = until { filter["until"] = serde_json::json!(value); } @@ -486,19 +502,21 @@ mod tests { // // `canvas_head_filter` and `canvas_ancestry_filter` build the JSON that // `current_canvas_head` and `current_canvas_head_ancestry` pass to - // `query_relay`. These tests assert the `"consistency"` field is - // present/absent in the produced filter, and that the async functions - // delegate to those helpers (the helpers ARE the production code path — - // there is no copy). + // `query_relay`. `get_canvas_filter` and `get_canvas_history_base_filter` + // build the corresponding display filters. These tests assert the + // `"consistency"` field is present/absent in the produced filter, and that + // each command delegates to its helper (the helpers ARE the production code + // path — there is no copy). // // Mutation oracle: // * Removing `"consistency"` from `canvas_head_filter` → the // `head_filter_carries_strong_consistency` test fails. // * Removing `"consistency"` from `canvas_ancestry_filter` → the // `ancestry_filter_carries_strong_consistency` test fails. - // * `get_canvas` builds its filter inline without `"consistency"` → the - // `get_canvas_filter_does_not_carry_consistency` test stays green (it - // asserts absence), serving as the inverse guard. + // * Adding `"consistency"` to `get_canvas_filter` → the + // `get_canvas_filter_does_not_carry_consistency` test fails. + // * Adding `"consistency"` to `get_canvas_history_base_filter` → the + // `get_canvas_history_base_filter_does_not_carry_consistency` test fails. #[test] fn head_filter_carries_strong_consistency() { @@ -541,23 +559,32 @@ mod tests { ); } - /// `get_canvas` is a display read: it must NOT carry `"consistency"`. This - /// is the inverse guard — if `consistency` were accidentally injected into - /// the display filter, this test would catch it. + /// `get_canvas` and `get_canvas_history` are display reads: they must NOT + /// carry `"consistency"`. These inverse guards call the actual production + /// filter builders — adding `"consistency"` to either builder turns the + /// relevant test red immediately (unlike a copied literal, which would + /// silently stay green while production drifted). #[test] fn get_canvas_filter_does_not_carry_consistency() { - // The `get_canvas` filter is built inline in the handler; replicate it - // here so any future addition of `consistency` to that literal fails - // this test. Note: this is intentionally a copy so it catches divergence - // from the production literal — the mutation oracle is the comparison. - let f = serde_json::json!({ - "kinds": [40100], - "#h": ["326d56bc-c96c-4af0-86a1-5e804cd1b467"], - "limit": 1 - }); + let f = super::get_canvas_filter("326d56bc-c96c-4af0-86a1-5e804cd1b467"); assert!( f.get("consistency").is_none(), "display (get_canvas) filter must NOT carry consistency: {f}" ); + // Structural sanity. + assert_eq!(f["kinds"], serde_json::json!([40100])); + assert_eq!(f.get("limit").and_then(|v| v.as_u64()), Some(1)); + } + + #[test] + fn get_canvas_history_base_filter_does_not_carry_consistency() { + let f = super::get_canvas_history_base_filter("326d56bc-c96c-4af0-86a1-5e804cd1b467", 50); + assert!( + f.get("consistency").is_none(), + "display (get_canvas_history) filter must NOT carry consistency: {f}" + ); + // Structural sanity. + assert_eq!(f["kinds"], serde_json::json!([40100])); + assert_eq!(f.get("limit").and_then(|v| v.as_u64()), Some(50)); } } From 8ddaeea2659953d9cb27863ed361173777198e2e Mon Sep 17 00:00:00 2001 From: Duncan Date: Sat, 29 Aug 2026 13:45:54 -0400 Subject: [PATCH 24/44] fix(canvas): gate ingress on eventId, lower future-skew ceiling to 60 s, add relay ingest guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Carl findings from review 5058583405: 1. Canvas ingress (ChannelManagementSheet) now keys existence on eventId != null rather than content length. Restoring to empty leaves a live kind:40100 revision; read-only members must reach it via the ingress row. Extracted canvasIngressOpen() into canvasIngress.ts; ChannelManagementSheet imports it; ChannelCanvasIngressGating.test.mjs covers all four cases with a mutation oracle (3 reds confirmed on the reverted content-only gating). 2. canvas_write_created_at_at(head = now+900, now) returned now+901, which the relay general ±900 s timestamp check rejects. Fixed on two levels: - Client (buzz-sdk builders.rs): CANVAS_MAX_FUTURE_SKEW_SECS 900 → 60. A ceiling head at now+60 produces now+61, well inside the relay bounds. - Relay (buzz-relay ingest.rs): new CANVAS_MAX_INGEST_FUTURE_SECS = 300 s kind-40100-specific guard. Extracted as validate_canvas_future_timestamp() for testability; canvas_ingest_future_timestamp_boundary() covers the at-ceiling (accepted), at-ceiling+1 (rejected), +901 (rejected), and past (accepted) cases. Invariant: client ceiling (60 s) < relay canvas bound (300 s) < relay general bound (900 s). CLI test set_stamps_ahead_of_future_head_and_asserts_it updated to use now+30 (within the new 60 s ceiling). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/channels.rs | 4 +- crates/buzz-relay/src/handlers/ingest.rs | 61 +++++++++++++++++ crates/buzz-sdk/src/builders.rs | 23 ++++--- .../ui/ChannelCanvasIngressGating.test.mjs | 66 +++++++++++++++++++ .../channels/ui/ChannelManagementSheet.tsx | 6 +- .../src/features/channels/ui/canvasIngress.ts | 14 ++++ 6 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs create mode 100644 desktop/src/features/channels/ui/canvasIngress.ts diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 594ff6ad83a..19033682d2b 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -3326,14 +3326,14 @@ mod set_canvas_tests { #[tokio::test] async fn set_stamps_ahead_of_future_head_and_asserts_it() { let head_id = "a".repeat(64); - // A head ahead of `now` but inside the 15-minute ceiling: `head + 1` + // A head ahead of `now` but inside the 60-second ceiling: `head + 1` // deterministically wins the `max`, and the guard accepts it. Computed // from `now` so it tracks the wall clock without a hardcoded date. let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - let future = now + 600; + let future = now + 30; let head = json!([{ "id": head_id, "pubkey": "b".repeat(64), diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ee1d0312be9..9c41e967e08 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2167,6 +2167,26 @@ pub async fn ingest_event( result } +/// Maximum seconds in the future a kind:40100 canvas event may be timestamped. +/// Tighter than the general ±900 s drift window to prevent a ceiling-timestamped +/// head from producing a write at `head + 1` that the relay would accept (the +/// boundary head itself is within ±900 s) but that permanently stalls all later +/// legitimate writes behind an inflated floor. Invariant: +/// client ceiling (60 s, CANVAS_MAX_FUTURE_SKEW_SECS) < canvas relay bound (300 s) < relay general bound (900 s) +const CANVAS_MAX_INGEST_FUTURE_SECS: i64 = 300; + +/// Returns `Ok(())` if the canvas event timestamp is within the allowed future +/// window, or `Err` with a rejection message otherwise. +/// +/// Extracted as a pure function so the boundary can be regression-tested without +/// a live database or HTTP stack. +fn validate_canvas_future_timestamp(event_ts: i64, now: i64) -> Result<(), &'static str> { + if event_ts - now > CANVAS_MAX_INGEST_FUTURE_SECS { + return Err("invalid: canvas event timestamp too far in the future"); + } + Ok(()) +} + async fn ingest_event_inner( state: &Arc, tracer: &Arc, @@ -2240,6 +2260,14 @@ async fn ingest_event_inner( )); } + // kind:40100 canvas events carry a tighter future ceiling — see + // `validate_canvas_future_timestamp` for the rationale and invariant. + if kind_u32 == KIND_CANVAS { + if let Err(msg) = validate_canvas_future_timestamp(event_ts, now) { + return Err(IngestError::Rejected(msg.into())); + } + } + const MAX_EVENT_CONTENT_BYTES: usize = 256 * 1024; // 256 KB if event.content.len() > MAX_EVENT_CONTENT_BYTES { return Err(IngestError::Rejected(format!( @@ -5537,4 +5565,37 @@ mod postgres_tests { Some(&1) ); } + + /// Boundary regression for the canvas-specific ingest future-timestamp guard. + /// `validate_canvas_future_timestamp` is the pure seam; mutation: changing + /// `CANVAS_MAX_INGEST_FUTURE_SECS` to 900 or removing the guard makes the + /// "at ceiling + 1" case pass when it must not. + #[test] + fn canvas_ingest_future_timestamp_boundary() { + let now = 1_700_000_000i64; + + // Exactly at the ceiling: accepted. + assert!( + validate_canvas_future_timestamp(now + CANVAS_MAX_INGEST_FUTURE_SECS, now).is_ok(), + "canvas event at now+300 is within the relay canvas future bound" + ); + + // One second past the ceiling: rejected. + assert!( + validate_canvas_future_timestamp(now + CANVAS_MAX_INGEST_FUTURE_SECS + 1, now).is_err(), + "canvas event at now+301 exceeds the relay canvas future bound and must be rejected" + ); + + // Past the general ±900 s window: also rejected (guard fires first). + assert!( + validate_canvas_future_timestamp(now + 901, now).is_err(), + "canvas event at now+901 exceeds both the canvas bound and the general drift window" + ); + + // In the past: accepted (canvas guard is future-only; general past check is separate). + assert!( + validate_canvas_future_timestamp(now - 1, now).is_ok(), + "canvas event in the past is not affected by the future-timestamp guard" + ); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index ac87a6b5132..8a74a2e6d08 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -620,9 +620,14 @@ pub fn build_set_canvas_after_head( /// CANVAS_MAX_FUTURE_SKEW_SECS` is treated as poisoned: stamping /// `max(now, head + 1)` against it would silently extend a bogus timeline /// arbitrarily far ahead, and every later legitimate write would inherit that -/// floor. Relay-side ingestion bounds are follow-up work; this stops -/// first-party clients from propagating the poison in the meantime. -pub const CANVAS_MAX_FUTURE_SKEW_SECS: u64 = 900; +/// floor. +/// +/// This is kept well below the relay's general ±900 s drift window. A ceiling +/// head at `now + 60` produces a write at `now + 61`, which is within the relay's +/// kind-40100-specific future bound (`CANVAS_MAX_INGEST_FUTURE_SECS = 300` in +/// ingest.rs) and far inside the general ±900 s window — first-party clients +/// can never construct an ingest-rejected event through ordinary use. +pub const CANVAS_MAX_FUTURE_SKEW_SECS: u64 = 60; /// Contract-v3 writer-discipline timestamp for a canvas write asserting a head /// at `head_created_at`: `max(now, head_created_at + 1)` (Unix seconds). @@ -646,13 +651,11 @@ pub fn canvas_write_created_at(head_created_at: u64) -> Result { /// Pure core of [`canvas_write_created_at`] with `now` injected — the clock /// seam. The public wrapper reads the real clock; tests drive the exact -/// `now + 900` / `now + 901` boundaries against a fixed `now`. +/// `now + 60` / `now + 61` boundaries against a fixed `now`. fn canvas_write_created_at_at(head_created_at: u64, now: u64) -> Result { if head_created_at > now.saturating_add(CANVAS_MAX_FUTURE_SKEW_SECS) { return Err(SdkError::InvalidInput( - "canvas head is timestamped too far in the future; refusing to extend it — \ - relay-side bounds are follow-up work" - .into(), + "canvas head is timestamped too far in the future; refusing to extend it".into(), )); } Ok(now.max(head_created_at.saturating_add(1))) @@ -3229,21 +3232,21 @@ mod tests { fn canvas_write_created_at_skew_boundary() { // Fixed clock: the injected-`now` core removes the second-rollover seam // that a real-clock read introduces. A head exactly at the ceiling - // (`now + 900`) is accepted and stamped strictly ahead; one second past + // (`now + 60`) is accepted and stamped strictly ahead; one second past // it is rejected as poisoned. let now = 1_700_000_000u64; let at_ceiling = now + CANVAS_MAX_FUTURE_SKEW_SECS; assert_eq!( canvas_write_created_at_at(at_ceiling, now).unwrap(), at_ceiling + 1, - "a head at now+900 is accepted and stamped strictly ahead" + "a head at now+60 is accepted and stamped strictly ahead" ); assert!( matches!( canvas_write_created_at_at(at_ceiling + 1, now), Err(SdkError::InvalidInput(_)) ), - "a head at now+901 is poisoned and rejected" + "a head at now+61 is poisoned and rejected" ); } diff --git a/desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs b/desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs new file mode 100644 index 00000000000..056e6c66782 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs @@ -0,0 +1,66 @@ +/** + * Canvas ingress gating regression: `canOpenCanvas` must key on the presence of + * a persisted relay revision (`eventId !== null`), not on content length. After + * a restore-to-empty the relay holds a kind:40100 event with `event_id` set and + * `content: ""` — a read-only member who cannot edit would lose the only ingress + * to that revision stream if we gate on `hasCanvas` (content truthiness). + * + * Tests the `canvasIngressOpen` pure function that ChannelManagementSheet + * delegates to for the `canOpenCanvas` flag. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +const { canvasIngressOpen } = await import("./canvasIngress.ts"); + +const EVENT_ID = "a".repeat(64); + +// Read-only member (canEditNarrative = false). + +test("read-only member: no persisted canvas → ingress closed", () => { + assert.equal(canvasIngressOpen(null, false), false); + assert.equal(canvasIngressOpen(undefined, false), false); +}); + +test("read-only member: persisted canvas with content → ingress open", () => { + assert.equal(canvasIngressOpen(EVENT_ID, false), true); +}); + +test("read-only member: persisted empty canvas (content='') with eventId → ingress open", () => { + // This is the restored-to-empty case. Content is "" but a revision exists. + // The old `hasCanvas || canEditNarrative` gating would return false here, + // losing the only ingress to the revision stream for read-only members. + assert.equal(canvasIngressOpen(EVENT_ID, false), true); +}); + +// Editor (canEditNarrative = true) — always open regardless of eventId. + +test("editor: no persisted canvas → ingress open (seeds first revision)", () => { + assert.equal(canvasIngressOpen(null, true), true); + assert.equal(canvasIngressOpen(undefined, true), true); +}); + +test("editor: persisted canvas → ingress open", () => { + assert.equal(canvasIngressOpen(EVENT_ID, true), true); +}); + +// Mutation oracle: confirms the test catches the content-based regression. + +test("regression oracle: old content-only gating fails for read-only + persisted-empty", () => { + // Old code: `hasCanvas || canEditNarrative` where hasCanvas = content.trim().length > 0. + // For an empty-content revision, hadOldBug === false — ingress closed for read-only. + const emptyContent = ""; + const hadOldBug = emptyContent.trim().length > 0 || false; + assert.equal( + hadOldBug, + false, + "old logic closes ingress for read-only + empty content", + ); + // canvasIngressOpen must NOT replicate that defect. + assert.equal( + canvasIngressOpen(EVENT_ID, false), + true, + "canvasIngressOpen keeps ingress open when eventId is non-null", + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index fb84dc96744..01941893d15 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -21,6 +21,7 @@ import { useUnarchiveChannelMutation, useUpdateChannelMutation, } from "@/features/channels/hooks"; +import { canvasIngressOpen } from "./canvasIngress"; import { compareMembersByRole } from "@/features/channels/lib/memberUtils"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelWorkflowsQuery } from "@/features/workflows/hooks"; @@ -309,7 +310,10 @@ export function ChannelManagementSheet({ const canvasPreview = hasCanvas ? getMarkdownPreviewText(canvasContent) : undefined; - const canOpenCanvas = hasCanvas || canEditNarrative; + const canOpenCanvas = canvasIngressOpen( + canvasQuery.data?.eventId, + canEditNarrative, + ); function handleEditDialogOpenChange(next: boolean) { if (next) { diff --git a/desktop/src/features/channels/ui/canvasIngress.ts b/desktop/src/features/channels/ui/canvasIngress.ts new file mode 100644 index 00000000000..56d41984f79 --- /dev/null +++ b/desktop/src/features/channels/ui/canvasIngress.ts @@ -0,0 +1,14 @@ +/** + * Whether the canvas ingress row should be shown. + * + * Existence is keyed on `eventId` (a persisted kind:40100 revision exists on + * the relay), not on content length — a restore to empty still leaves a live + * revision, and a read-only member must be able to reach it. `canEditNarrative` + * independently grants access so editors can seed the first revision. + */ +export function canvasIngressOpen( + eventId: string | null | undefined, + canEditNarrative: boolean, +): boolean { + return eventId != null || canEditNarrative; +} From 3622baf58186ab16e0c86635a6453374d771e9a6 Mon Sep 17 00:00:00 2001 From: Duncan Date: Sat, 29 Aug 2026 16:08:35 -0400 Subject: [PATCH 25/44] test(canvas): close three causality gaps from round-7 (sheet wiring, ingest wiring, boundary self-reference) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 (sheet wiring): ChannelCanvasIngressGating.test.mjs now includes two source-wiring assertions that read ChannelManagementSheet.tsx directly. Reverting the call site to 'hasCanvas || canEditNarrative' removes the canvasIngressOpen call → assert.match fails. 8/8 pass at head; 2/8 fail on sheet-call-site mutation. Fix 2 (ingest wiring): two new ignored Postgres tests prove the kind-40100 canvas guard is wired in the shipping ingest path: - handlers::ingest::tests::canvas_ingest_guard_wired_through_ingest_event_inner: calls ingest_event_inner directly with now+301, asserts the canvas- specific rejection message; guard deletion changes it to the h-tag message. - api::bridge::tests::canvas_ingest_future_timestamp_guard_is_wired: goes through the full HTTP router; now+301 must produce the canvas rejection body; guard deletion produces the membership-check body. Both confirm the local Postgres PASS at authorship. Two new CI steps in Backend Integration select them by exact name with --run-ignored ignored-only (same pattern as the existing writer-pin step). Fix 3 (boundary self-reference): added canvas_ingest_numeric_contract (ingest.rs) and canvas_write_created_at_numeric_contract (builders.rs) — standalone tests using ONLY fixed numeric literals, no constants. 300→900 and 60→900 mutations both fail on the new contracts and on the existing boundary tests (verified locally with both mutations). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/_ci-relay.yml | 35 ++++ crates/buzz-relay/src/api/bridge.rs | 112 +++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 190 ++++++++++++++++++ crates/buzz-sdk/src/builders.rs | 61 ++++++ .../ui/ChannelCanvasIngressGating.test.mjs | 33 +++ 5 files changed, 431 insertions(+) diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index a5d01b960a2..5444f77fee3 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -551,6 +551,41 @@ jobs: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz REDIS_URL: redis://localhost:6379 + - name: Canvas ingest guard HTTP wiring test + # Proves the kind-40100 future-timestamp guard is wired in the full HTTP + # ingest path (post_events → router → ingest_event_inner). A canvas event + # at relay_now+300 must NOT be rejected 400; at relay_now+301 must be + # rejected 400 by the canvas-specific guard (not the general ±900 s bound). + # Mutation oracle: deleting `if kind_u32 == KIND_CANVAS { … }` in + # ingest_event_inner makes the +301 case return non-400, failing the test. + # #[ignore]d in the default suite — see + # api::bridge::tests::canvas_ingest_future_timestamp_guard_is_wired. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(=api::bridge::tests::canvas_ingest_future_timestamp_guard_is_wired)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + REDIS_URL: redis://localhost:6379 + - name: Canvas ingest guard ingest_event_inner wiring test + # Confirms the canvas future-timestamp guard fires through ingest_event_inner + # directly (not via the HTTP router). A kind-40100 event at relay_now+301 + # must be rejected with the canvas-specific message, not the h-tag message. + # Mutation oracle: deleting the guard call site changes the rejection reason + # to the h-tag check, failing the message assertion. + # #[ignore]d in the default suite — see + # handlers::ingest::tests::canvas_ingest_guard_wired_through_ingest_event_inner. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(=handlers::ingest::tests::canvas_ingest_guard_wired_through_ingest_event_inner)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + REDIS_URL: redis://localhost:6379 - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 9aef3df7cc1..dab93bf1c36 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -4061,6 +4061,36 @@ mod postgres_tests { .status() } + /// Like `post_events` but also returns the UTF-8 response body. + async fn post_events_with_body( + state: Arc, + host: &str, + pubkey_hex: &str, + body: &[u8], + ) -> (axum::http::StatusCode, String) { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let resp = crate::router::build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/events") + .header(header::HOST, host) + .header("x-pubkey", pubkey_hex) + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read response body"); + (status, String::from_utf8_lossy(&bytes).into_owned()) + } + /// Collect buzz_events_rejected_total with (transport, reason) labels from /// a DebuggingRecorder snapshot. fn http_reject_counts( @@ -4204,6 +4234,88 @@ mod postgres_tests { ); } + /// Canvas ingest wiring regression: the kind-40100-specific future-timestamp + /// guard in `ingest_event_inner` is actually wired to the shipping call path. + /// + /// Calls `post_events_with_body` → router → `submit_event` → `ingest_event_inner`: + /// - A canvas event with `created_at = relay_now + 301` is rejected 400 with + /// the canvas-specific error "canvas event timestamp too far in the future". + /// + /// Discriminating: deleting the `if kind_u32 == KIND_CANVAS { … }` call in + /// `ingest_event_inner` removes the guard. The event then reaches the channel + /// membership check (no h-tag channel exists → "restricted: not a channel + /// member"), making the message assertion below fail with a different body. + /// + /// Note: both +301 and +300 are within the general ±900 s drift window; + /// only the canvas-specific guard distinguishes them at 300 s. This test is + /// therefore exclusively sensitive to the guard being wired, not to the + /// general drift check. + #[test] + #[ignore = "requires Postgres"] + fn canvas_ingest_future_timestamp_guard_is_wired() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(bridge_handler_test_state()) else { + panic!("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests"); + }; + + let host = { + let h = format!( + "canvas-ingest-wiring-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&h)) + .expect("ensure community"); + h + }; + + let client_keys = Keys::generate(); + let pubkey_hex = client_keys.public_key().to_hex(); + + // A canvas event 301 s in the future. The canvas ingest guard (300 s + // ceiling) fires BEFORE the channel membership check, rejecting with + // the canvas-specific error. The general ±900 s drift check admits this + // timestamp, so only the canvas guard can produce this rejection. + let relay_now = chrono::Utc::now().timestamp(); + // Use a random channel UUID that does NOT exist in the DB. If the canvas + // guard is correctly wired, it fires first; if deleted, the event reaches + // the membership check and the body says "not a channel member" instead. + let channel_id = uuid::Uuid::new_v4().to_string(); + let event_past_ceiling = + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "") + .tag(Tag::parse(["h", channel_id.as_str()]).expect("h tag")) + .custom_created_at(nostr::Timestamp::from( + (relay_now + 301).try_into().unwrap_or(0u64), + )) + .sign_with_keys(&client_keys) + .expect("sign canvas event past ceiling"); + let body_bytes = + serde_json::to_vec(&event_past_ceiling).expect("serialize event past ceiling"); + + let (status, body) = rt.block_on(post_events_with_body( + state.clone(), + &host, + &pubkey_hex, + &body_bytes, + )); + + // Must be 400 AND the body must name the canvas guard (not the membership check). + // Mutation oracle: deleting the `if kind_u32 == KIND_CANVAS { … }` guard + // makes the body say "not a channel member" instead, failing both assertions. + assert_eq!( + status, + axum::http::StatusCode::BAD_REQUEST, + "canvas event at relay_now+301 must be rejected 400; body: {body}", + ); + assert!( + body.contains("canvas event timestamp too far in the future"), + "rejection body must name the canvas guard (not the membership check). Got: {body}; mutation oracle: delete the guard call site → body becomes 'not a channel member'", + ); + } + // ────────────────────────────────────────────────────────────────────────── // Log-capture helpers and attribution-invariant tests // diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9c41e967e08..e153ffb0212 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -5570,6 +5570,12 @@ mod postgres_tests { /// `validate_canvas_future_timestamp` is the pure seam; mutation: changing /// `CANVAS_MAX_INGEST_FUTURE_SECS` to 900 or removing the guard makes the /// "at ceiling + 1" case pass when it must not. + /// + /// Fixed-literal contract (pinned so constant mutations go red): the relay + /// canvas bound IS 300 s; now+300 accepted, now+301 rejected. The relay + /// general bound is 900 s (a separate guard); the client ceiling is 60 s + /// (CANVAS_MAX_FUTURE_SKEW_SECS in buzz-sdk). Mutating the 300 s constant + /// back to 900 makes the numeric assertions below fail. #[test] fn canvas_ingest_future_timestamp_boundary() { let now = 1_700_000_000i64; @@ -5597,5 +5603,189 @@ mod postgres_tests { validate_canvas_future_timestamp(now - 1, now).is_ok(), "canvas event in the past is not affected by the future-timestamp guard" ); + + // Fixed-literal contract: relay canvas bound IS 300 s, NOT 900 s. + // Mutating CANVAS_MAX_INGEST_FUTURE_SECS back to 900 makes these fail. + assert!( + validate_canvas_future_timestamp(now + 300, now).is_ok(), + "now+300: accepted at the 300 s relay canvas ceiling" + ); + assert!( + validate_canvas_future_timestamp(now + 301, now).is_err(), + "now+301: rejected one second past the 300 s relay canvas ceiling" + ); + // The old 900 s value must be rejected by this guard. + assert!( + validate_canvas_future_timestamp(now + 900, now).is_err(), + "now+900 must be rejected by the 300 s relay canvas ceiling" + ); + } + + /// Standalone numeric contract for the relay-side canvas ingest guard. + /// + /// No constants used — if CANVAS_MAX_INGEST_FUTURE_SECS changes, this test + /// catches it regardless of whether constant-based assertions remain + /// self-consistent. The relay canvas ceiling IS 300 s: now+300 is the last + /// accepted timestamp; now+301 is the first rejected timestamp. + #[test] + fn canvas_ingest_numeric_contract() { + let now = 1_700_000_000i64; + // These assertions use only fixed numeric literals; they cannot be + // self-referential regardless of what CANVAS_MAX_INGEST_FUTURE_SECS holds. + assert!( + validate_canvas_future_timestamp(now + 300, now).is_ok(), + "now+300 must be accepted: relay canvas ceiling is 300 s", + ); + assert!( + validate_canvas_future_timestamp(now + 301, now).is_err(), + "now+301 must be rejected: one second past the 300 s relay canvas ceiling", + ); + // Old 900 s value must also be rejected (prevents silent reversion to + // the general drift bound). + assert!( + validate_canvas_future_timestamp(now + 900, now).is_err(), + "now+900 must be rejected: the general 900 s bound does not apply to canvas events", + ); + } + + /// Ingest-path wiring regression: the kind-40100 canvas future-timestamp + /// guard in `ingest_event_inner` must be exercised through the real ingest + /// path, not only the pure `validate_canvas_future_timestamp` helper. + /// + /// A signed kind-40100 event with `created_at = relay_now + 301` is + /// submitted through `ingest_event_inner`. It must be rejected with the + /// canvas-specific error "canvas event timestamp too far in the future". + /// + /// Mutation oracle: deleting the `if kind_u32 == KIND_CANVAS { … }` call + /// site in `ingest_event_inner` changes the rejection reason to the h-tag + /// check ("channel-scoped events must include an h tag"), causing this + /// assertion to fail. + /// + /// Infrastructure: a real Postgres is required to pass the community + /// deletion-fence check that precedes the canvas guard. Redis is not + /// needed — the canvas guard fires before any Redis-backed path. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn canvas_ingest_guard_wired_through_ingest_event_inner() { + use buzz_auth::Nip98ReplayGuard; + use nostr::{Keys, Kind, Timestamp}; + + const FAKE_REDIS_URL: &str = "redis://127.0.0.1:1"; // no Redis needed for this path + + // ── Postgres connection ────────────────────────────────────────────── + let db_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + let pool = sqlx::PgPool::connect(&db_url).await.expect( + "connect test Postgres — start local Postgres before running ignored ingest tests", + ); + let db = buzz_db::Db::from_pool(pool.clone()); + // Do not call db.migrate() here: CI migrates the schema before running + // integration tests; calling migrate() locally risks version conflicts + // if the DB was provisioned via a different path. + + // ── AppState ───────────────────────────────────────────────────────── + // Redis is lazy and never actually contacted on this rejection path. + let redis_pool = deadpool_redis::Config::from_url(FAKE_REDIS_URL) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("deadpool redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(FAKE_REDIS_URL, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let mut config = crate::config::Config::from_env().expect("relay config from env"); + config.database_url = db_url.clone(); + config.redis_url = FAKE_REDIS_URL.to_string(); + config.require_relay_membership = false; + + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth_svc = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db.clone(), + redis_pool, + audit, + pubsub, + auth_svc, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + + // Replace the NIP-98 replay guard so no live Redis is required. + struct AlwaysFreshReplayGuard; + impl Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async { Ok(true) }) + } + } + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + let state = Arc::new(state); + + // ── Provision a fresh community so the deletion fence allows writes ── + let host = format!("canvas-ts-guard-{}.test", Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = TenantContext::resolved(community, &host); + + // ── Build a kind-40100 event 301 seconds in the future ─────────────── + let keys = Keys::generate(); + let relay_now = chrono::Utc::now().timestamp() as u64; + let event = nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "") + .custom_created_at(Timestamp::from(relay_now + 301)) + .sign_with_keys(&keys) + .expect("sign canvas event"); + + let auth = IngestAuth::Http { + pubkey: keys.public_key(), + scopes: vec![Scope::ChannelsWrite], + auth_method: HttpAuthMethod::Nip98, + }; + let tracer: Arc = Arc::new(VecTracer::default()); + + // ── Submit through the real ingest path ─────────────────────────────── + let result = ingest_event_inner(&state, &tracer, &tenant, event, auth).await; + + // The canvas-specific guard must fire before the h-tag check. + // Fixed boundary: created_at = relay_now + 301 exceeds the 300 s canvas + // ceiling, so the guard rejects with this exact message. + // + // Mutation oracle: delete `if kind_u32 == KIND_CANVAS { … }` in + // ingest_event_inner → no canvas guard fires → the event reaches the + // h-tag check → Rejected("invalid: channel-scoped events must include + // an h tag") → assert_eq! below fails. + let err = match result { + Ok(_) => panic!( + "kind-40100 event at now+301 must be rejected, but ingest_event_inner returned Ok" + ), + Err(e) => e, + }; + assert!( + matches!(&err, IngestError::Rejected(msg) if msg.contains("canvas event timestamp too far in the future")), + "rejection must be the canvas guard, not the h-tag check; deleting the guard call site changes this error to the h-tag rejection. Got: {err:?}", + ); } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 8a74a2e6d08..bf7c7b5ab41 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -3234,6 +3234,13 @@ mod tests { // that a real-clock read introduces. A head exactly at the ceiling // (`now + 60`) is accepted and stamped strictly ahead; one second past // it is rejected as poisoned. + // + // Invariant contract (pinned with literals, not constants, so a constant + // mutation also fails this test): the client ceiling is 60 s; the relay + // canvas ingest bound is 300 s; the relay general drift bound is 900 s. + // A ceiling head at now+60 produces now+61, well below both relay bounds. + // These numeric assertions must stay consistent with CANVAS_MAX_FUTURE_SKEW_SECS + // and the relay's CANVAS_MAX_INGEST_FUTURE_SECS (300) / MAX_TIMESTAMP_DRIFT_SECS (900). let now = 1_700_000_000u64; let at_ceiling = now + CANVAS_MAX_FUTURE_SKEW_SECS; assert_eq!( @@ -3248,6 +3255,60 @@ mod tests { ), "a head at now+61 is poisoned and rejected" ); + + // Fixed-literal contract: client ceiling IS 60 s, NOT the old 900 s. + // Mutating CANVAS_MAX_FUTURE_SKEW_SECS back to 900 makes these fail. + assert!( + canvas_write_created_at_at(now + 60, now).is_ok(), + "now+60 is within the 60 s client ceiling" + ); + assert!( + matches!( + canvas_write_created_at_at(now + 61, now), + Err(SdkError::InvalidInput(_)) + ), + "now+61 is one second past the 60 s client ceiling" + ); + // Confirm the old 900 s ceiling is now rejected (prevents silent + // reversion): a head at now+900 must not be ratcheted past. + assert!( + matches!( + canvas_write_created_at_at(now + 900, now), + Err(SdkError::InvalidInput(_)) + ), + "now+900 must be rejected by the 60 s client ceiling" + ); + } + + /// Standalone numeric contract for the client-side canvas future-skew ceiling. + /// + /// No constants used — if CANVAS_MAX_FUTURE_SKEW_SECS changes, this test + /// catches it regardless of whether the constant-based assertions remain + /// self-consistent. The client ceiling IS 60 s: now+60 is the last accepted + /// head; now+61 is the first rejected head. + /// + /// Cross-crate invariant: the relay canvas ingest bound is 300 s and the + /// relay general drift bound is 900 s. A ceiling head at now+60 is stamped + /// now+61, comfortably below both relay bounds. + #[test] + fn canvas_write_created_at_numeric_contract() { + let now = 1_700_000_000u64; + // The client ceiling is exactly 60 s — not 900 s (the old value). + // These assertions use only fixed numeric literals; they cannot be + // self-referential regardless of what CANVAS_MAX_FUTURE_SKEW_SECS holds. + assert!( + canvas_write_created_at_at(now + 60, now).is_ok(), + "now+60 must be accepted: client ceiling is 60 s", + ); + assert!( + canvas_write_created_at_at(now + 61, now).is_err(), + "now+61 must be rejected: one second past the 60 s client ceiling", + ); + // Old 900 s value must also be rejected (prevents silent reversion). + assert!( + canvas_write_created_at_at(now + 900, now).is_err(), + "now+900 must be rejected: the old 900 s ceiling is no longer valid", + ); } #[test] diff --git a/desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs b/desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs index 056e6c66782..dcc887c8515 100644 --- a/desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs +++ b/desktop/src/features/channels/ui/ChannelCanvasIngressGating.test.mjs @@ -10,10 +10,43 @@ */ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; const { canvasIngressOpen } = await import("./canvasIngress.ts"); +// Source-wiring oracle: read ChannelManagementSheet.tsx and verify it calls +// `canvasIngressOpen` with `eventId` as the existence signal, not with +// `hasCanvas` (content-length truthiness). Reverting the sheet call site to +// `hasCanvas || canEditNarrative` removes the `canvasIngressOpen` call from +// `canOpenCanvas` and makes the match below fail. +const sheetSource = readFileSync( + new URL("./ChannelManagementSheet.tsx", import.meta.url), + "utf8", +).replace(/\s+/g, " "); + +test("ChannelManagementSheet: canOpenCanvas is wired to canvasIngressOpen with eventId", () => { + // The sheet must delegate existence gating to the canonical helper, passing + // `canvasQuery.data?.eventId` so persisted-empty canvases are not hidden from + // read-only members. Reverting to `hasCanvas || canEditNarrative` removes + // this call and the assertion below fails. + assert.match( + sheetSource, + /canvasIngressOpen\( canvasQuery\.data\?\.eventId,/, + "canOpenCanvas must call canvasIngressOpen(canvasQuery.data?.eventId, …)", + ); +}); + +test("ChannelManagementSheet: hasCanvas is not used as the ingress-open predicate", () => { + // `hasCanvas` is a content-length check valid only for preview text. It must + // NOT be the existence gate for canOpenCanvas. + assert.doesNotMatch( + sheetSource, + /canOpenCanvas = hasCanvas/, + "canOpenCanvas must not be derived directly from hasCanvas", + ); +}); + const EVENT_ID = "a".repeat(64); // Read-only member (canEditNarrative = false). From c90fdc62f618751ca1bd9fa1cdaf82b4d0fe5bad Mon Sep 17 00:00:00 2001 From: Duncan Date: Sat, 29 Aug 2026 17:15:39 -0400 Subject: [PATCH 26/44] test(canvas): use interior +600 offset in ingest wiring tests to eliminate clock-race flakiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two real-ingest wiring tests previously signed events at relay_now+301 and relied on the guard firing before production re-sampled Utc::now(). Thufir reproduced a green-head FAIL (1.72s run): scheduler latency shrunk the nominal +301 to within the 300 s ceiling, letting the event through and changing the rejection reason to the h-tag check. Fix: move both wiring tests to relay_now+600. The +600 offset sits 300 s above the canvas ceiling (300 s) and 300 s below the general drift bound (900 s). Scheduler latency would need to exceed 300 s to erase the margin — not possible under any realistic load. The oracle mechanism is unchanged: guard deletion changes the rejection body from the canvas-specific message to the h-tag (direct) or membership (HTTP) message. Exact 300/301 boundary coverage remains in the pure fixed-literal tests (canvas_ingest_numeric_contract, canvas_ingest_future_timestamp_boundary), which pass fixed arguments to validate_canvas_future_timestamp and have no clock race. Update ci.yml step comments to reflect this separation. Verified locally: - 3/3 direct-ingest runs green at +600 (1.23s, 1.97s, 1.81s) - HTTP bridge run green at +600 (2.02s) - Deletion mutation (guard commented out): direct FAIL with h-tag message, HTTP FAIL with 'not a channel member' body — both oracles causal Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/_ci-relay.yml | 14 +++++--- crates/buzz-relay/src/api/bridge.rs | 45 ++++++++++++++---------- crates/buzz-relay/src/handlers/ingest.rs | 33 +++++++++++------ 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 5444f77fee3..eee6b2b4c31 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -554,10 +554,13 @@ jobs: - name: Canvas ingest guard HTTP wiring test # Proves the kind-40100 future-timestamp guard is wired in the full HTTP # ingest path (post_events → router → ingest_event_inner). A canvas event - # at relay_now+300 must NOT be rejected 400; at relay_now+301 must be - # rejected 400 by the canvas-specific guard (not the general ±900 s bound). + # at relay_now+600 must be rejected 400 by the canvas-specific guard. + # +600 sits 300 s above the canvas ceiling (300 s) and 300 s below the + # general drift bound (900 s), so scheduler latency cannot erase the margin. + # Exact 300/301 boundary coverage is in the pure fixed-literal tests. # Mutation oracle: deleting `if kind_u32 == KIND_CANVAS { … }` in - # ingest_event_inner makes the +301 case return non-400, failing the test. + # ingest_event_inner makes the +600 case pass the general drift check and + # reach the membership check, changing the body to "not a channel member". # #[ignore]d in the default suite — see # api::bridge::tests::canvas_ingest_future_timestamp_guard_is_wired. run: | @@ -571,8 +574,11 @@ jobs: REDIS_URL: redis://localhost:6379 - name: Canvas ingest guard ingest_event_inner wiring test # Confirms the canvas future-timestamp guard fires through ingest_event_inner - # directly (not via the HTTP router). A kind-40100 event at relay_now+301 + # directly (not via the HTTP router). A kind-40100 event at relay_now+600 # must be rejected with the canvas-specific message, not the h-tag message. + # +600 sits 300 s above the canvas ceiling so scheduler latency cannot + # shrink the apparent offset to within 300 s. + # Exact 300/301 boundary coverage is in the pure fixed-literal tests. # Mutation oracle: deleting the guard call site changes the rejection reason # to the h-tag check, failing the message assertion. # #[ignore]d in the default suite — see diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index dab93bf1c36..ab2930faab3 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -4238,18 +4238,22 @@ mod postgres_tests { /// guard in `ingest_event_inner` is actually wired to the shipping call path. /// /// Calls `post_events_with_body` → router → `submit_event` → `ingest_event_inner`: - /// - A canvas event with `created_at = relay_now + 301` is rejected 400 with + /// - A canvas event with `created_at = relay_now + 600` is rejected 400 with /// the canvas-specific error "canvas event timestamp too far in the future". /// - /// Discriminating: deleting the `if kind_u32 == KIND_CANVAS { … }` call in - /// `ingest_event_inner` removes the guard. The event then reaches the channel - /// membership check (no h-tag channel exists → "restricted: not a channel - /// member"), making the message assertion below fail with a different body. + /// The +600 offset sits 300 s above the canvas ceiling (300 s) and 300 s + /// below the general drift bound (900 s). Scheduler latency between test + /// setup and production's independent `Utc::now()` re-sample would need to + /// exceed 300 s to erode the margin — not possible under any realistic load. + /// Exact 300/301 boundary coverage lives in the pure `validate_canvas_future_timestamp` + /// tests (`canvas_ingest_numeric_contract`, `canvas_ingest_future_timestamp_boundary`), + /// which pass fixed arguments and have no clock race. /// - /// Note: both +301 and +300 are within the general ±900 s drift window; - /// only the canvas-specific guard distinguishes them at 300 s. This test is - /// therefore exclusively sensitive to the guard being wired, not to the - /// general drift check. + /// Discriminating: deleting the `if kind_u32 == KIND_CANVAS { … }` call in + /// `ingest_event_inner` removes the guard. The event then passes the general + /// ±900 s drift check (600 s < 900 s) and reaches the channel membership + /// check (no h-tag channel exists → "restricted: not a channel member"), + /// making the message assertion below fail with a different body. #[test] #[ignore = "requires Postgres"] fn canvas_ingest_future_timestamp_guard_is_wired() { @@ -4275,20 +4279,23 @@ mod postgres_tests { let client_keys = Keys::generate(); let pubkey_hex = client_keys.public_key().to_hex(); - // A canvas event 301 s in the future. The canvas ingest guard (300 s - // ceiling) fires BEFORE the channel membership check, rejecting with - // the canvas-specific error. The general ±900 s drift check admits this - // timestamp, so only the canvas guard can produce this rejection. + // A canvas event 600 s in the future. The +600 offset sits 300 s above + // the canvas ceiling and 300 s below the general ±900 s drift bound, so + // only the canvas guard can produce a rejection here. Scheduler latency + // between this Utc::now() call and production's independent re-sample + // would need to exceed 300 s to erode the margin — impossible in practice. + // Exact 300/301 boundary assertions live in the pure fixed-literal tests. let relay_now = chrono::Utc::now().timestamp(); // Use a random channel UUID that does NOT exist in the DB. If the canvas - // guard is correctly wired, it fires first; if deleted, the event reaches - // the membership check and the body says "not a channel member" instead. + // guard is correctly wired, it fires first; if deleted, the event passes + // the general ±900 s check (600 < 900) and reaches the membership check, + // producing "not a channel member" instead of the canvas rejection. let channel_id = uuid::Uuid::new_v4().to_string(); let event_past_ceiling = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "") .tag(Tag::parse(["h", channel_id.as_str()]).expect("h tag")) .custom_created_at(nostr::Timestamp::from( - (relay_now + 301).try_into().unwrap_or(0u64), + (relay_now + 600).try_into().unwrap_or(0u64), )) .sign_with_keys(&client_keys) .expect("sign canvas event past ceiling"); @@ -4308,11 +4315,13 @@ mod postgres_tests { assert_eq!( status, axum::http::StatusCode::BAD_REQUEST, - "canvas event at relay_now+301 must be rejected 400; body: {body}", + "canvas event at relay_now+600 must be rejected 400; body: {body}", ); assert!( body.contains("canvas event timestamp too far in the future"), - "rejection body must name the canvas guard (not the membership check). Got: {body}; mutation oracle: delete the guard call site → body becomes 'not a channel member'", + "rejection body must name the canvas guard (not the membership check). \ + Got: {body}; mutation oracle: delete the guard call site → body becomes \ + 'not a channel member'", ); } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index e153ffb0212..efb883731e6 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -5652,9 +5652,15 @@ mod postgres_tests { /// guard in `ingest_event_inner` must be exercised through the real ingest /// path, not only the pure `validate_canvas_future_timestamp` helper. /// - /// A signed kind-40100 event with `created_at = relay_now + 301` is - /// submitted through `ingest_event_inner`. It must be rejected with the - /// canvas-specific error "canvas event timestamp too far in the future". + /// A signed kind-40100 event with `created_at = relay_now + 600` is + /// submitted through `ingest_event_inner`. The offset is chosen to be + /// well inside the guard's rejection zone (300 s ceiling) so that + /// scheduler latency between test setup and production's `Utc::now()` + /// re-sample cannot shrink the apparent offset to within 300 s and + /// accidentally let the event through. Exact 300/301 boundary coverage + /// lives in `canvas_ingest_numeric_contract` and + /// `canvas_ingest_future_timestamp_boundary`, which exercise the pure + /// `validate_canvas_future_timestamp` helper with fixed arguments. /// /// Mutation oracle: deleting the `if kind_u32 == KIND_CANVAS { … }` call /// site in `ingest_event_inner` changes the rejection reason to the h-tag @@ -5751,11 +5757,16 @@ mod postgres_tests { .id; let tenant = TenantContext::resolved(community, &host); - // ── Build a kind-40100 event 301 seconds in the future ─────────────── + // ── Build a kind-40100 event 600 seconds in the future ─────────────── + // +600 is well inside the guard's rejection zone (>300 s), so scheduler + // latency between this Utc::now() call and production's independent + // Utc::now() re-sample inside ingest_event_inner cannot close the gap + // to within 300 s. Exact 300/301 boundary assertions live in the pure + // `validate_canvas_future_timestamp` tests which have no clock race. let keys = Keys::generate(); let relay_now = chrono::Utc::now().timestamp() as u64; let event = nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "") - .custom_created_at(Timestamp::from(relay_now + 301)) + .custom_created_at(Timestamp::from(relay_now + 600)) .sign_with_keys(&keys) .expect("sign canvas event"); @@ -5770,22 +5781,24 @@ mod postgres_tests { let result = ingest_event_inner(&state, &tracer, &tenant, event, auth).await; // The canvas-specific guard must fire before the h-tag check. - // Fixed boundary: created_at = relay_now + 301 exceeds the 300 s canvas - // ceiling, so the guard rejects with this exact message. + // created_at = relay_now + 600 is 300 s above the canvas ceiling, so + // even under heavy load the guard fires and rejects with this message. // // Mutation oracle: delete `if kind_u32 == KIND_CANVAS { … }` in // ingest_event_inner → no canvas guard fires → the event reaches the // h-tag check → Rejected("invalid: channel-scoped events must include - // an h tag") → assert_eq! below fails. + // an h tag") → assert! below fails. let err = match result { Ok(_) => panic!( - "kind-40100 event at now+301 must be rejected, but ingest_event_inner returned Ok" + "kind-40100 event at now+600 must be rejected, but ingest_event_inner returned Ok" ), Err(e) => e, }; assert!( matches!(&err, IngestError::Rejected(msg) if msg.contains("canvas event timestamp too far in the future")), - "rejection must be the canvas guard, not the h-tag check; deleting the guard call site changes this error to the h-tag rejection. Got: {err:?}", + "rejection must be the canvas guard, not the h-tag check; \ + deleting the guard call site changes this error to the h-tag rejection. \ + Got: {err:?}", ); } } From 86ee1da7e4318eff70b3beef876b29ec8e2be903 Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 31 Aug 2026 14:01:22 -0400 Subject: [PATCH 27/44] feat(canvas): add DB-level CAS for kind-40100 writes and wire through relay ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add atomic compare-and-insert for canvas (kind-40100) writes: a DB-level advisory-lock CAS that serializes concurrent edits on the same canvas, and relay-side parsing + dispatch for the `expected-revision` precondition tag. The CLI `canvas set` command remains an unconditional replace (no `expected-revision` tag) but still applies writer-pinned timestamp discipline: `created_at = max(now, head + 1)` so an accepted write always becomes the canonical visible head. buzz-db: - Add `ChannelHeadWriteStatus` enum: Inserted, Duplicate, RevisionMissing, RevisionMismatch, SupersedeFailed - Add `ChannelHeadPrecondition`: ExpectNoHead | ExpectedHead(Vec) - Add `insert_channel_head_checked`: acquires pg_advisory_xact_lock keyed on (community, kind, channel) — excludes author so concurrent writers serialize rather than silently stomp — reads the current head, evaluates the precondition, short-circuits idempotent replay, then calls insert_event_with_thread_metadata_tx + insert_mentions_in_transaction in the same transaction - Re-export ChannelHeadPrecondition and ChannelHeadWriteStatus from buzz-db lib buzz-relay: - Add `CanvasRevisionSpec` enum and `parse_canvas_expected_revision` parser: rejects duplicate/malformed tags; accepts absent (None), "none" (NoHead), or 64-hex id (Head) - Wire CAS dispatch in ingest_event_inner between parameterized-replaceable and generic append: RevisionMissing/RevisionMismatch/SupersedeFailed map to conflict: rejections; Inserted/Duplicate fall through normally - Add 7 parser unit tests covering every branch of parse_canvas_expected_revision buzz-sdk: - Add `build_set_canvas_unconditional_after_head`: stamps created_at = max(now, head+1) for ordering discipline but passes expected_revision=None — no CAS tag buzz-cli: - Rewrite cmd_set_canvas: reads head for timestamp discipline, calls build_set_canvas_unconditional_after_head when head exists, falls back to build_set_canvas(None) when no head. No expected-revision tag emitted. - Add/update set_canvas_tests: set_stamps_ahead_of_future_head_and_is_untagged asserts created_at=head+1 AND no expected-revision tag; set_with_no_head_creates_ without_expected_revision asserts no tag on first create Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-cli/src/commands/channels.rs | 102 ++-- crates/buzz-db/src/lib.rs | 1 + crates/buzz-db/src/store/event.rs | 702 +++++++++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 241 ++++++++ crates/buzz-sdk/src/builders.rs | 20 + 5 files changed, 1002 insertions(+), 64 deletions(-) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 19033682d2b..f1d2c7f4e67 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -1712,26 +1712,29 @@ pub async fn cmd_set_canvas( let content = read_or_stdin(content)?; let channel_uuid = parse_uuid(channel_id)?; - // `set` is an unconditional replace — a moved head is not an error, so it - // never returns the restore path's conflict/exit-5, and it takes no - // post-write supersession check (it asserts no loaded revision to survive). - // It does apply the same writer discipline: read the head and stamp + // `set` is an unconditional replace — no `expected-revision` tag, so the + // relay accepts the write regardless of the current head. A moved head is + // not an error; there is no post-write supersession check. + // + // Writer discipline still applies: read the head and stamp // `created_at = max(now, head + 1)` so the write sorts strictly ahead of a - // newer or future-dated head under `created_at DESC, id ASC` instead of - // reporting success behind it. That stamping runs through - // `build_set_canvas_after_head`, so `set` also inherits its skew guard: a - // head timestamped more than 15 minutes in the future is refused with a - // clear error rather than extending a poisoned timeline. With no head yet, - // `Some("none")` records the create-assertion at the default `now`. + // newer or future-dated head under `created_at DESC, id ASC`. This keeps + // an accepted append from landing behind the current head in read order, + // which would "succeed" without changing the visible canvas. + // + // The skew guard applies: a head timestamped more than CANVAS_MAX_FUTURE_SKEW_SECS + // in the future is refused with a clear error rather than extending a poisoned + // timeline. With no head yet, `build_set_canvas` with no tag stamps at `now`. let builder = match fetch_canvas_head(client, channel_id, true).await? { Some(head) => { - let head_id = head.get("id").and_then(|v| v.as_str()).ok_or_else(|| { - CliError::Other(format!("no canvas head id found for channel {channel_id}")) - })?; let head_created_at = head.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); - buzz_sdk::build_set_canvas_after_head(channel_uuid, &content, head_id, head_created_at) + buzz_sdk::build_set_canvas_unconditional_after_head( + channel_uuid, + &content, + head_created_at, + ) } - None => buzz_sdk::build_set_canvas(channel_uuid, &content, Some("none")), + None => buzz_sdk::build_set_canvas(channel_uuid, &content, None), } .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; @@ -3209,11 +3212,11 @@ mod tests { } /// Command-level coverage for `cmd_set_canvas`'s writer discipline: it must read -/// the head, stamp `created_at` strictly ahead of a future-dated head, carry the -/// head's id as the `expected-revision` tag, fall back to an `expected-revision: -/// none` create when no head exists, and refuse to submit against a structurally -/// malformed head. These pin the fix so a revert to `created_at = now` / no head -/// read fails a test rather than silently recreating the false-success bug. +/// the head, stamp `created_at` strictly ahead of a future-dated head, emit NO +/// `expected-revision` tag (unconditional replace), fall back to stamping at `now` +/// when no head exists, and refuse to submit against a structurally poisoned head. +/// These pin the fix so a revert to `created_at = now` / no head read fails a test +/// rather than silently recreating the false-success bug. /// /// Each test spins up a local axum relay that answers `POST /query` with a fixed /// head and captures the event submitted to `POST /events`, then inspects the @@ -3320,11 +3323,11 @@ mod set_canvas_tests { /// A future-dated head (within the skew ceiling) forces the /// `max(now, head + 1)` bump to resolve to `head + 1` deterministically (no - /// clock dependence, no sleeps), so the submitted revision sorts strictly - /// ahead of the head under `created_at DESC, id ASC` and carries the head id - /// as `expected-revision`. + /// clock dependence, no sleeps), so the submitted event sorts strictly + /// ahead of the head under `created_at DESC, id ASC`. `set` is unconditional: + /// no `expected-revision` tag is emitted even though the head was read. #[tokio::test] - async fn set_stamps_ahead_of_future_head_and_asserts_it() { + async fn set_stamps_ahead_of_future_head_and_is_untagged() { let head_id = "a".repeat(64); // A head ahead of `now` but inside the 60-second ceiling: `head + 1` // deterministically wins the `max`, and the guard accepts it. Computed @@ -3354,10 +3357,11 @@ mod set_canvas_tests { Some(future + 1), "must stamp strictly ahead of the future-dated head" ); + // `set` is unconditional — no expected-revision tag regardless of head presence. assert_eq!( - tag_value(&event, "expected-revision").as_deref(), - Some(head_id.as_str()), - "must assert the head id it read" + tag_value(&event, "expected-revision"), + None, + "set must NOT emit expected-revision — it is an unconditional replace" ); assert_eq!(event.get("kind").and_then(|v| v.as_u64()), Some(40100)); } @@ -3391,10 +3395,11 @@ mod set_canvas_tests { ); } - /// No head yet: `set` still submits (create is not an error) and records the - /// create-assertion `expected-revision: none`. + /// No head yet: `set` still submits (create is not an error) and does NOT + /// emit an `expected-revision` tag — `set` is unconditional regardless of + /// head presence. #[tokio::test] - async fn set_with_no_head_creates_with_expected_revision_none() { + async fn set_with_no_head_creates_without_expected_revision() { let (url, submitted, _) = relay("[]").await; cmd_set_canvas(&client(&url), CHANNEL, "first content") @@ -3403,40 +3408,9 @@ mod set_canvas_tests { let event = submitted.lock().unwrap().clone().expect("set must submit"); assert_eq!( - tag_value(&event, "expected-revision").as_deref(), - Some("none"), - "no-head create must assert expected-revision: none" - ); - } - - /// A structurally malformed head (row without `id`) is an ordinary error, not - /// a conflict, and nothing is published — the exact boundary the fix added. - #[tokio::test] - async fn set_errors_on_head_without_id_and_does_not_submit() { - let head = json!([{ - "pubkey": "b".repeat(64), - "kind": 40100, - "content": "old", - "created_at": 1_700_000_000u64, - "tags": [["h", CHANNEL]], - }]); - let (url, submitted, _) = relay(&head.to_string()).await; - - let err = cmd_set_canvas(&client(&url), CHANNEL, "new content") - .await - .expect_err("malformed head must error"); - - assert!( - matches!(err, CliError::Other(_)), - "expected Other, got {err:?}" - ); - assert!( - err.to_string().contains("no canvas head id found"), - "unexpected message: {err}" - ); - assert!( - submitted.lock().unwrap().is_none(), - "no event may be published when the head is malformed" + tag_value(&event, "expected-revision"), + None, + "set must NOT emit expected-revision even on first create" ); } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 6f8d0ffb3d4..9a8f77edfb2 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -61,6 +61,7 @@ pub use community::{ UnarchivedCommunityRecord, }; pub use error::{DbError, Result}; +pub use event::{ChannelHeadPrecondition, ChannelHeadWriteStatus}; pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; pub use reaction::ReactionEventInsertOutcome; pub use reminder::DueReminder; diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 61e37c5b1c4..ceb22639e05 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -1527,6 +1527,188 @@ pub async fn insert_event_with_thread_metadata( Ok(result) } +/// Outcome of a channel-head conditional canvas write. +/// +/// Canvas events (kind 40100) are plain appends that accrue as version history; +/// the "current" canvas is the live head under `created_at DESC, id ASC`. When a +/// write carries an `expected-revision` precondition, this enum reports whether +/// it matched. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChannelHeadWriteStatus { + /// The event was appended as the new channel head. + Inserted, + /// The exact event already existed (idempotent replay of the current head). + Duplicate, + /// `ExpectedHead` was required but no live head exists for the channel/kind. + RevisionMissing, + /// The live head differs from the required `ExpectedHead` (or `ExpectNoHead` + /// was required but a head already exists). + RevisionMismatch, + /// The precondition matched, but the candidate does not sort strictly ahead + /// of the current head under `created_at DESC, id ASC`, so accepting it + /// would leave the visible canvas unchanged. Rejected to preserve the + /// invariant that an accepted tagged write IS the new head. + SupersedeFailed, +} + +/// Optimistic-concurrency precondition for [`insert_channel_head_checked`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChannelHeadPrecondition<'a> { + /// Require that no live head exists yet (first creation of the canvas). + ExpectNoHead, + /// Require the live head to match this validated 32-byte event ID. + ExpectedHead(&'a [u8]), +} + +/// Whether a candidate channel-head event sorts strictly ahead of the current +/// head under the canonical `created_at DESC, id ASC` ordering — i.e. whether +/// accepting it actually makes it the new head. +/// +/// The head is the row with the greatest `created_at`, ties broken by the +/// lowest `id`. So the candidate wins iff it has a later `created_at`, or an +/// equal `created_at` with a strictly lower `id`. +fn candidate_supersedes_head( + candidate: &Event, + candidate_id: &[u8; 32], + head_created_at: DateTime, + head_id: &[u8], +) -> bool { + let candidate_secs = candidate.created_at.as_secs() as i64; + let head_secs = head_created_at.timestamp(); + match candidate_secs.cmp(&head_secs) { + std::cmp::Ordering::Greater => true, + std::cmp::Ordering::Less => false, + std::cmp::Ordering::Equal => candidate_id.as_slice() < head_id, + } +} + +/// Conditionally append a channel-head event (canvas kind 40100) under an +/// optimistic-concurrency precondition. +/// +/// The check and the insert share one advisory lock keyed on +/// `(community, kind, channel)` — deliberately excluding the author, since any +/// channel member edits the same canvas and cross-author concurrent edits must +/// serialize on the same head. The head is read as `created_at DESC, id ASC` +/// (matching the read path), the precondition is evaluated, and on success the +/// event plus its mentions are inserted in the same transaction. Precondition +/// failures mutate nothing. +/// +/// # Head-advancement guarantee +/// +/// A matching `ExpectedHead` precondition additionally requires the candidate +/// to sort strictly ahead of the current head under `created_at DESC, id ASC`. +/// Otherwise the write would be accepted and fanned out yet leave the visible +/// head unchanged — a same-second lower-id or behind-clock writer would +/// "succeed" without restoring the selected content or advancing the canvas. +/// Such writes reject as [`ChannelHeadWriteStatus::SupersedeFailed`]. First-party +/// writers sign `created_at = max(now, head.created_at + 1)`, so this reject is +/// practically unreachable outside clock pathology. +/// +/// # Idempotent replay exception +/// +/// Re-submitting the byte-identical event that already is the live head returns +/// [`ChannelHeadWriteStatus::Duplicate`] without evaluating the supplied +/// precondition. A duplicate insert cannot change state, so evaluating its +/// precondition buys nothing and would turn a safe transport retry (identical +/// bytes replayed after a lost response) into a false conflict. +pub async fn insert_channel_head_checked( + pool: &PgPool, + community_id: CommunityId, + event: &Event, + channel_id: Uuid, + precondition: ChannelHeadPrecondition<'_>, +) -> Result<(StoredEvent, ChannelHeadWriteStatus)> { + use crate::store::replaceable::event_replacement_lock_key; + + let kind_i32 = buzz_core::kind::event_kind_i32(event); + let received_at = Utc::now(); + let incoming_id = event.id.as_bytes(); + + let mut tx = pool.begin().await?; + + // Serialize check+insert per (community, kind, channel). The author is + // intentionally excluded from the key so cross-author concurrent edits + // contend on the same lock. + let lock_key = event_replacement_lock_key( + community_id, + kind_i32, + &[], + Some(channel_id.as_bytes().as_slice()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + + let head: Option<(Vec, DateTime)> = sqlx::query_as( + "SELECT id, created_at FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(channel_id) + .fetch_optional(&mut *tx) + .await?; + + // Idempotent replay: the incoming event already is the live head. Returns + // before precondition evaluation — a duplicate insert cannot change state, + // so a byte-identical transport retry must never surface as a false conflict. + if head + .as_ref() + .is_some_and(|(id, _)| id.as_slice() == incoming_id.as_slice()) + { + tx.rollback().await?; + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, Some(channel_id), true), + ChannelHeadWriteStatus::Duplicate, + )); + } + + let status = match (precondition, head.as_ref()) { + (ChannelHeadPrecondition::ExpectNoHead, None) => None, + (ChannelHeadPrecondition::ExpectNoHead, Some(_)) => { + Some(ChannelHeadWriteStatus::RevisionMismatch) + } + (ChannelHeadPrecondition::ExpectedHead(_), None) => { + Some(ChannelHeadWriteStatus::RevisionMissing) + } + (ChannelHeadPrecondition::ExpectedHead(expected), Some((id, head_created_at))) => { + if id.as_slice() != expected { + Some(ChannelHeadWriteStatus::RevisionMismatch) + } else if !candidate_supersedes_head(event, incoming_id, *head_created_at, id) { + // Precondition matched, but the candidate cannot become the + // head under `created_at DESC, id ASC`. Accepting it would leave + // the visible canvas unchanged. + Some(ChannelHeadWriteStatus::SupersedeFailed) + } else { + None + } + } + }; + if let Some(status) = status { + tx.rollback().await?; + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, Some(channel_id), false), + status, + )); + } + + let (stored, was_inserted) = + insert_event_with_thread_metadata_tx(&mut tx, community_id, event, Some(channel_id), None) + .await?; + if !was_inserted { + // Lost an insert race after passing the precondition (another writer + // committed the identical id). Treat as an idempotent duplicate. + tx.rollback().await?; + return Ok((stored, ChannelHeadWriteStatus::Duplicate)); + } + crate::insert_mentions_in_transaction(&mut tx, community_id, event, Some(channel_id)).await?; + tx.commit().await?; + + Ok((stored, ChannelHeadWriteStatus::Inserted)) +} + impl Db { /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. #[datastore_span(name = "insert_event", system = "postgresql")] @@ -2161,6 +2343,22 @@ pub async fn insert_reaction_event_with_thread_metadata( .await?; Ok(result.rows_affected()) } + + /// Conditionally append a canvas write (kind 40100) under an optimistic-concurrency + /// precondition. Delegates to [`insert_channel_head_checked`]. + /// + /// Always uses the writer pool — the precondition check and the insert must + /// be serialized on the writer to prevent TOCTOU races. + #[datastore_span(name = "insert_channel_head_checked", system = "postgresql")] + pub async fn insert_channel_head_checked( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Uuid, + precondition: ChannelHeadPrecondition<'_>, + ) -> Result<(StoredEvent, ChannelHeadWriteStatus)> { + insert_channel_head_checked(&self.pool, community_id, event, channel_id, precondition).await + } } #[cfg(test)] @@ -2902,4 +3100,508 @@ mod postgres_tests { assert!(!huddle_started_content_links(&wrong_field, channel_id)); assert!(!huddle_started_content_links("not-json", channel_id)); } + + // ─── canvas CAS tests ───────────────────────────────────────────────────── + + fn make_canvas_event_at(content: &str, created_at: u64) -> nostr::Event { + make_event_at(buzz_core::kind::KIND_CANVAS as u16, content, created_at) + } + + /// Build two canvas events sharing `created_at`, returned `(lower, higher)` + /// by event id. Regenerates until the ids differ (always, since keys differ) + /// so tests can assert deterministic head ordering on the id tiebreak. + fn same_second_ordered_pair(created_at: u64) -> (nostr::Event, nostr::Event) { + let a = make_canvas_event_at("# A", created_at); + let b = make_canvas_event_at("# B", created_at); + if a.id.as_bytes() <= b.id.as_bytes() { + (a, b) + } else { + (b, a) + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expect_no_head_creates_first_canvas() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# First", 1000); + + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + assert_eq!(status, ChannelHeadWriteStatus::Inserted); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expect_no_head_rejects_when_head_exists() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let first = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &first, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + let second = make_canvas_event_at("# Racing create", 1001); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &second, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("second create attempt"); + assert_eq!(status, ChannelHeadWriteStatus::RevisionMismatch); + + // The losing create must not have been persisted. + let persisted: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(second.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count losing create"); + assert_eq!(persisted, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expected_head_matches_and_advances() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let first = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &first, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + let second = make_canvas_event_at("# Second", 1001); + let head = first.id.as_bytes(); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &second, + channel, + ChannelHeadPrecondition::ExpectedHead(head.as_slice()), + ) + .await + .expect("edit against head"); + assert_eq!(status, ChannelHeadWriteStatus::Inserted); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expected_head_mismatch_rejects() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let first = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &first, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + let stale = make_canvas_event_at("# Stale edit", 1002); + let wrong_head = [0u8; 32]; + let (_, status) = insert_channel_head_checked( + &pool, + community, + &stale, + channel, + ChannelHeadPrecondition::ExpectedHead(&wrong_head), + ) + .await + .expect("stale edit attempt"); + assert_eq!(status, ChannelHeadWriteStatus::RevisionMismatch); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_expected_head_missing_rejects() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# Edit with no head", 1000); + let some_head = [1u8; 32]; + + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectedHead(&some_head), + ) + .await + .expect("edit with no head"); + assert_eq!(status, ChannelHeadWriteStatus::RevisionMissing); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_replay_of_head_is_duplicate() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + // Replaying the exact head under ExpectedHead(head) is idempotent. + let head = event.id.as_bytes(); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectedHead(head.as_slice()), + ) + .await + .expect("replay head"); + assert_eq!(status, ChannelHeadWriteStatus::Duplicate); + } + + /// Idempotent-replay exception: replaying the byte-identical head succeeds + /// as a no-op even when the supplied precondition would otherwise conflict. + /// Precondition is not evaluated for a duplicate, so safe transport retry + /// never becomes a false conflict. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_replay_skips_stale_precondition() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let event = make_canvas_event_at("# First", 1000); + insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas"); + + // Replay the same bytes with a now-stale `ExpectNoHead` tag: a head + // exists, so the precondition would reject — but replay short-circuits. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("replay with stale none tag"); + assert_eq!(status, ChannelHeadWriteStatus::Duplicate); + } + + /// Head-advancement guarantee: a candidate whose `created_at` equals the + /// head's but whose id is HIGHER cannot become the head under + /// `created_at DESC, id ASC`, so it rejects even though the precondition + /// matches. Accepting it would leave the visible canvas unchanged. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_rejects_same_second_higher_id() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + let (lower, higher) = same_second_ordered_pair(1000); + insert_channel_head_checked( + &pool, + community, + &lower, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas is lower-id head"); + + // Candidate has the same created_at but a higher id → cannot supersede. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &higher, + channel, + ChannelHeadPrecondition::ExpectedHead(lower.id.as_bytes().as_slice()), + ) + .await + .expect("same-second higher-id edit"); + assert_eq!(status, ChannelHeadWriteStatus::SupersedeFailed); + + // The rejected write must not have been persisted. + let persisted: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(higher.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count rejected write"); + assert_eq!(persisted, 0); + } + + /// Head-advancement guarantee: a candidate at the same second with a LOWER + /// id does sort strictly ahead of the head, so it advances. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_accepts_same_second_lower_id() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + let (lower, higher) = same_second_ordered_pair(1000); + // Seed the higher-id event as the head first. + insert_channel_head_checked( + &pool, + community, + &higher, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("first canvas is higher-id head"); + + // Lower id at the same second sorts strictly ahead → advances. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &lower, + channel, + ChannelHeadPrecondition::ExpectedHead(higher.id.as_bytes().as_slice()), + ) + .await + .expect("same-second lower-id edit"); + assert_eq!(status, ChannelHeadWriteStatus::Inserted); + } + + /// Head-advancement guarantee: a behind-clock writer whose `created_at` + /// predates the head cannot supersede it, even with a matching precondition. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_rejects_behind_clock_writer() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let head = make_canvas_event_at("# Head", 2000); + insert_channel_head_checked( + &pool, + community, + &head, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("head at t=2000"); + + // Writer's clock is behind — created_at earlier than head. + let behind = make_canvas_event_at("# Behind clock", 1100); + let (_, status) = insert_channel_head_checked( + &pool, + community, + &behind, + channel, + ChannelHeadPrecondition::ExpectedHead(head.id.as_bytes().as_slice()), + ) + .await + .expect("behind-clock edit"); + assert_eq!(status, ChannelHeadWriteStatus::SupersedeFailed); + } + + /// Race soundness: two concurrent authors both assert the same live head and + /// both sign strictly-advancing writes. The per-(community, channel) advisory + /// lock must serialize check+insert so exactly one wins (`Inserted`) and the + /// other observes the moved head (`RevisionMismatch`). Exactly one write may + /// become the visible head. + /// + /// Mutation oracle: removing the `pg_advisory_xact_lock` call from + /// `insert_channel_head_checked` causes this test to fail under concurrent + /// load (both writers can observe the same head simultaneously). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_concurrent_authors_only_one_advances() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + let base = make_canvas_event_at("# Base", 1000); + insert_channel_head_checked( + &pool, + community, + &base, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("seed base head"); + let base_id = base.id.to_bytes().to_vec(); + + // Both edits assert `base` as their head and are timestamped strictly + // ahead of it (writer discipline), so neither trips SupersedeFailed — + // only the advisory-lock serialization decides the winner. + let a = make_canvas_event_at("# Author A", 1001); + let b = make_canvas_event_at("# Author B", 1002); + + let (pool_a, pool_b) = (pool.clone(), pool.clone()); + let (id_a, id_b) = (base_id.clone(), base_id.clone()); + let ta = tokio::spawn(async move { + insert_channel_head_checked( + &pool_a, + community, + &a, + channel, + ChannelHeadPrecondition::ExpectedHead(&id_a), + ) + .await + .map(|(_, status)| status) + }); + let tb = tokio::spawn(async move { + insert_channel_head_checked( + &pool_b, + community, + &b, + channel, + ChannelHeadPrecondition::ExpectedHead(&id_b), + ) + .await + .map(|(_, status)| status) + }); + + let status_a = ta.await.expect("join A").expect("insert A"); + let status_b = tb.await.expect("join B").expect("insert B"); + + let mut statuses = [status_a, status_b]; + statuses.sort_by_key(|s| format!("{s:?}")); + assert_eq!( + statuses, + [ + ChannelHeadWriteStatus::Inserted, + ChannelHeadWriteStatus::RevisionMismatch + ], + "exactly one concurrent write advances the head; got {statuses:?}" + ); + + // Exactly one new canvas row (beyond the base) was committed. + let head_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("count committed canvas rows"); + assert_eq!(head_count, 2, "base plus exactly one winning edit"); + } + + /// Race soundness for first-create: two writers both assert `ExpectNoHead` + /// simultaneously. Exactly one must be `Inserted`; the other gets + /// `RevisionMismatch`. The loser must not be persisted. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_concurrent_first_create_only_one_wins() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + let a = make_canvas_event_at("# First A", 1000); + let b = make_canvas_event_at("# First B", 1001); + + let (pool_a, pool_b) = (pool.clone(), pool.clone()); + let ta = tokio::spawn(async move { + insert_channel_head_checked( + &pool_a, + community, + &a, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .map(|(stored, status)| (stored.id.to_vec(), status)) + }); + let tb = tokio::spawn(async move { + insert_channel_head_checked( + &pool_b, + community, + &b, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .map(|(stored, status)| (stored.id.to_vec(), status)) + }); + + let (id_a, status_a) = ta.await.expect("join A").expect("call A"); + let (id_b, status_b) = tb.await.expect("join B").expect("call B"); + + let inserted = if status_a == ChannelHeadWriteStatus::Inserted { + assert_eq!(status_b, ChannelHeadWriteStatus::RevisionMismatch); + id_a + } else { + assert_eq!(status_a, ChannelHeadWriteStatus::RevisionMismatch); + assert_eq!(status_b, ChannelHeadWriteStatus::Inserted); + id_b + }; + + // Exactly one canvas row exists. + let head_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("count canvas rows"); + assert_eq!(head_count, 1, "exactly one first-create winner"); + + // The stored head is the winner. + let head_id: Vec = sqlx::query_scalar( + "SELECT id FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("read head"); + assert_eq!(head_id, inserted, "stored head must be the winner"); + } } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index efb883731e6..0e29be6d12f 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -191,6 +191,60 @@ fn validate_reaction_emoji(event: &Event, emoji: &str) -> Result<(), IngestError Ok(()) } +/// A validated canvas `expected-revision` precondition. +/// +/// The tag value is either the literal `none` (expect no canvas head yet) or a +/// 64-hex event ID (expect the live head to match it). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CanvasRevisionSpec { + /// Literal `none` — the writer expects no canvas head to exist. + NoHead, + /// A 32-byte event ID the live canvas head must equal. + Head(Vec), +} + +/// Parse the optional canvas `expected-revision` precondition from an event. +/// +/// Returns `Ok(None)` when the tag is absent (backward-compatible unconditional +/// append). The tag shape is exactly `["expected-revision", value]`: a +/// one-element `["expected-revision"]` or any three-or-more-element form is +/// malformed and rejects `invalid:` — it is never treated as absent. At most +/// one `expected-revision` tag may be present. A single well-formed tag yields +/// `Some(spec)`. +pub(crate) fn parse_canvas_expected_revision( + event: &Event, +) -> Result, IngestError> { + let mut tags = event + .tags + .iter() + .map(nostr::Tag::as_slice) + .filter(|parts| parts.first().map(String::as_str) == Some("expected-revision")); + + let Some(tag) = tags.next() else { + return Ok(None); + }; + if tags.next().is_some() { + return Err(IngestError::Rejected( + "invalid: duplicate expected-revision tag".into(), + )); + } + if tag.len() != 2 { + return Err(IngestError::Rejected( + "invalid: expected-revision tag must have exactly one value".into(), + )); + } + let value = tag[1].as_str(); + + if value == "none" { + return Ok(Some(CanvasRevisionSpec::NoHead)); + } + let bytes = hex::decode(value) + .ok() + .filter(|bytes| bytes.len() == 32) + .ok_or_else(|| IngestError::Rejected("invalid: bad expected canvas revision".into()))?; + Ok(Some(CanvasRevisionSpec::Head(bytes))) +} + /// How the HTTP caller authenticated (for [`IngestAuth::Http`]). #[derive(Debug, Clone)] pub enum HttpAuthMethod { @@ -3172,6 +3226,15 @@ async fn ingest_event_inner( }); } + // Parse a canvas `expected-revision` precondition once, ahead of the write + // dispatch. Malformed or duplicate tags reject here (never reaching the DB); + // an absent tag yields `None`, routing canvas writes to the generic append. + let canvas_revision_spec = if kind_u32 == KIND_CANVAS { + parse_canvas_expected_revision(&event)? + } else { + None + }; + let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. @@ -3195,6 +3258,42 @@ async fn ingest_event_inner( .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) .await .map_err(|e| IngestError::Internal(format!("error: {e}")))? + } else if let Some(spec) = canvas_revision_spec.as_ref() { + // Canvas write carrying an optimistic-concurrency precondition. Plain + // canvas writes (no `expected-revision` tag) fall through to the generic + // append path below, preserving unconditional behavior. The channel is + // guaranteed present here: KIND_CANVAS requires an `h` tag and step 5b + // resolved it into `channel_id`. + let channel = channel_id + .ok_or_else(|| IngestError::Rejected("invalid: canvas event missing channel".into()))?; + let precondition = match spec { + CanvasRevisionSpec::NoHead => buzz_db::ChannelHeadPrecondition::ExpectNoHead, + CanvasRevisionSpec::Head(id) => buzz_db::ChannelHeadPrecondition::ExpectedHead(id), + }; + let (stored_event, status) = state + .db + .insert_channel_head_checked(tenant.community(), &event, channel, precondition) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + match status { + buzz_db::ChannelHeadWriteStatus::RevisionMissing => { + return Err(IngestError::Rejected( + "conflict: canvas revision does not exist".into(), + )); + } + buzz_db::ChannelHeadWriteStatus::RevisionMismatch => { + return Err(IngestError::Rejected( + "conflict: canvas changed since it was loaded".into(), + )); + } + buzz_db::ChannelHeadWriteStatus::SupersedeFailed => { + return Err(IngestError::Rejected( + "conflict: canvas write does not supersede the current head".into(), + )); + } + buzz_db::ChannelHeadWriteStatus::Inserted => (stored_event, true), + buzz_db::ChannelHeadWriteStatus::Duplicate => (stored_event, false), + } } else { let thread_params = thread_meta.as_ref().map(|m| m.as_params()); match state @@ -5801,4 +5900,146 @@ mod postgres_tests { Got: {err:?}", ); } + + // ── parse_canvas_expected_revision unit tests ───────────────────────── + // + // These cover every branch in the parser without requiring Postgres or + // Redis. A helper builds a signed kind-40100 event from a tag-list so the + // tests only state the tags they care about. + + /// Build a signed kind-40100 event carrying the given tags. The event is + /// fully signed so the nostr library populates `tags` correctly; content + /// and timestamp are irrelevant for the parser. + fn canvas_event_with_tags(tags: impl IntoIterator) -> nostr::Event { + use nostr::{EventBuilder, Keys, Kind}; + EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign canvas event for parser test") + } + + /// No `expected-revision` tag → `Ok(None)` (backward-compatible unconditional + /// append; mutation: adding a spurious tag-match makes the call return + /// `Some`, changing the Ok(None) assertion to fail). + #[test] + fn parse_canvas_revision_absent_returns_none() { + let event = canvas_event_with_tags([nostr::Tag::parse(["h", "chan-uuid"]).unwrap()]); + let result = parse_canvas_expected_revision(&event); + assert_eq!(result.unwrap(), None); + } + + /// `expected-revision = "none"` → `Ok(Some(NoHead))` (first-create + /// precondition; mutation: changing `"none"` check to `"NONE"` makes the + /// parser fall through to the hex decoder and return `Rejected`). + #[test] + fn parse_canvas_revision_none_literal_yields_no_head() { + let event = + canvas_event_with_tags([nostr::Tag::parse(["expected-revision", "none"]).unwrap()]); + assert_eq!( + parse_canvas_expected_revision(&event).unwrap(), + Some(CanvasRevisionSpec::NoHead), + ); + } + + /// A well-formed 64-hex event ID → `Ok(Some(Head(bytes)))` where the + /// bytes equal the decoded hex (mutation: changing `bytes.len() == 32` + /// to `!= 32` makes this return `Rejected`). + #[test] + fn parse_canvas_revision_valid_hex_yields_head() { + let hex_id = "a".repeat(64); + let event = + canvas_event_with_tags([nostr::Tag::parse(["expected-revision", &hex_id]).unwrap()]); + let spec = parse_canvas_expected_revision(&event) + .expect("valid hex must parse") + .expect("must be Some"); + assert_eq!(spec, CanvasRevisionSpec::Head(vec![0xaa; 32])); + } + + /// Two `expected-revision` tags → `Rejected("invalid: duplicate …")`. + /// Mutation: removing the `tags.next().is_some()` guard makes this return + /// `Ok(Some(…))` instead. + #[test] + fn parse_canvas_revision_duplicate_tag_rejects() { + let hex_id = "b".repeat(64); + let event = canvas_event_with_tags([ + nostr::Tag::parse(["expected-revision", &hex_id]).unwrap(), + nostr::Tag::parse(["expected-revision", &hex_id]).unwrap(), + ]); + assert!( + matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(msg)) if msg.contains("duplicate expected-revision tag") + ), + "duplicate tags must be rejected", + ); + } + + /// A one-element `["expected-revision"]` tag (no value) → `Rejected`. + /// Mutation: changing `tag.len() != 2` to `< 2` also catches zero-element + /// forms but not three-element; this case specifically exercises the + /// `len == 1` branch. + #[test] + fn parse_canvas_revision_missing_value_rejects() { + // nostr::Tag::parse requires ≥1 element; build a tag with only the key. + let event = canvas_event_with_tags([nostr::Tag::parse(["expected-revision"]).unwrap()]); + assert!( + matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(msg)) if msg.contains("expected-revision tag must have exactly one value") + ), + "tag with no value must be rejected", + ); + } + + /// A three-element `["expected-revision", value, extra]` tag → `Rejected`. + /// Mutation: changing `tag.len() != 2` to `tag.len() < 2` lets three-element + /// tags through; this test catches that. + #[test] + fn parse_canvas_revision_extra_value_rejects() { + let hex_id = "c".repeat(64); + let event = + canvas_event_with_tags([ + nostr::Tag::parse(["expected-revision", &hex_id, "extra"]).unwrap() + ]); + assert!( + matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(msg)) if msg.contains("expected-revision tag must have exactly one value") + ), + "tag with extra value must be rejected", + ); + } + + /// A 62-character hex string (too short — not a 32-byte id) → `Rejected`. + /// Mutation: removing the `bytes.len() == 32` length check makes this return + /// `Ok(Some(Head(…)))` with 31 bytes instead of rejecting. + #[test] + fn parse_canvas_revision_too_short_hex_rejects() { + let short_hex = "d".repeat(62); + let event = + canvas_event_with_tags([nostr::Tag::parse(["expected-revision", &short_hex]).unwrap()]); + assert!( + matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(msg)) if msg.contains("bad expected canvas revision") + ), + "too-short hex must be rejected", + ); + } + + /// A non-hex value → `Rejected("invalid: bad expected canvas revision")`. + /// Mutation: removing `hex::decode(value).ok()` makes this panic instead. + #[test] + fn parse_canvas_revision_non_hex_rejects() { + let not_hex = "g".repeat(64); // 'g' is not a valid hex digit + let event = + canvas_event_with_tags([nostr::Tag::parse(["expected-revision", ¬_hex]).unwrap()]); + assert!( + matches!( + parse_canvas_expected_revision(&event), + Err(IngestError::Rejected(msg)) if msg.contains("bad expected canvas revision") + ), + "non-hex value must be rejected", + ); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index bf7c7b5ab41..e20c0f2c1f2 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -615,6 +615,26 @@ pub fn build_set_canvas_after_head( .custom_created_at(nostr::Timestamp::from(created_at))) } +/// Build an unconditional canvas write (kind 40100) that still applies writer +/// discipline: stamps `created_at = max(now, head_created_at + 1)` so the +/// event sorts strictly ahead of the current head under `created_at DESC, id ASC`. +/// +/// Unlike [`build_set_canvas_after_head`], no `expected-revision` tag is added +/// — the write is an unconditional append that can never conflict. Use this for +/// `buzz canvas set`, which documents unconditional replace semantics. +/// +/// Returns an error if `head_created_at` is more than +/// [`CANVAS_MAX_FUTURE_SKEW_SECS`] beyond `now` (poisoned timeline guard). +pub fn build_set_canvas_unconditional_after_head( + channel_id: Uuid, + content: &str, + head_created_at: u64, +) -> Result { + let created_at = canvas_write_created_at(head_created_at)?; + Ok(build_set_canvas(channel_id, content, None)? + .custom_created_at(nostr::Timestamp::from(created_at))) +} + /// Maximum future skew a canvas head may carry before a first-party client /// refuses to ratchet past it (seconds). A head timestamped beyond `now + /// CANVAS_MAX_FUTURE_SKEW_SECS` is treated as poisoned: stamping From 419821959be062da13479c6337b866b388409a5e Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 31 Aug 2026 14:26:08 -0400 Subject: [PATCH 28/44] fix(canvas): add CAS ingest wiring test, CI steps, and trim event.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gates required by review: 1. Real ingest-path CAS test through ingest_event_inner: canvas_cas_dispatch_wired_through_ingest_event_inner proves the expected-revision parser → dispatch → DB transaction round-trip end-to-end. Steps: ExpectNoHead → Inserted, ExpectedHead match → Inserted, stale same-head competitor → conflict: rejection, loser absent from DB, untagged write → unconditional append via generic path. Mutation oracle: removing the canvas_revision_spec dispatch block makes the stale step return Ok, failing the assert. Also adds build_canvas_ingest_state helper to keep the test body focused. 2. CI Backend Integration steps for #[ignore]d CAS tests: - Canvas CAS DB tests: filter pattern channel_head_checked_/ selects all 12 DB tests; --no-tests=fail makes a typo exit with code 4, not silently pass. --test-threads=1 prevents advisory-lock key contention between concurrent test runs. - Canvas CAS ingest-path wiring test: exact-match selector for the new ingest wiring test. 3. Trim redundancy in event.rs: - ChannelHeadWriteStatus outer doc: 5-line context → 1 line - RevisionMismatch/SupersedeFailed variant docs: tightened - candidate_supersedes_head doc: 7 lines → 2 lines - insert_channel_head_checked doc: 28-line two-section doc → 12 lines - Redundant inline comments removed where the doc already covers Also fix unused CliError import in set_canvas_tests and two stored.event.id.to_vec() → to_bytes().to_vec() compile errors in concurrent-first-create test. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/_ci-relay.yml | 56 ++++++ crates/buzz-cli/src/commands/channels.rs | 1 - crates/buzz-db/src/store/event.rs | 76 +++----- crates/buzz-relay/src/handlers/ingest.rs | 222 +++++++++++++++++++++++ 4 files changed, 299 insertions(+), 56 deletions(-) diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index eee6b2b4c31..86f54e7ad15 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -592,6 +592,62 @@ jobs: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz REDIS_URL: redis://localhost:6379 + - name: Canvas CAS DB tests + # Transaction, advisory-lock, and precondition-evaluation coverage for the + # kind-40100 atomic compare-and-insert path in buzz-db. All 12 tests share + # the `channel_head_checked_` prefix; a typo in the filter exits with code 4 + # (nextest --no-tests=fail default) so silent zero-test runs are impossible. + # + # Covers: first-create (ExpectNoHead → Inserted), first-create rejection + # when head exists (ExpectNoHead → RevisionMismatch + loser absent), + # head-match advance (ExpectedHead match → Inserted), head-mismatch + # (ExpectedHead wrong id → RevisionMismatch), missing head (ExpectedHead + # but no head → RevisionMissing), idempotent replay (Duplicate short-circuit + # pre-precondition), replay-skips-stale-precondition, same-second higher-id + # SupersedeFailed + loser absent, same-second lower-id Inserted, behind-clock + # SupersedeFailed, concurrent-authors advisory-lock serialization, and + # concurrent-first-create race. + # + # Mutation oracle: removing `pg_advisory_xact_lock` from + # `insert_channel_head_checked` causes the concurrent tests to fail under + # load (both writers observe the same head). + # + # --test-threads=1: the concurrent tests spawn Tokio tasks against a + # shared pool; running them in isolation avoids cross-test contention on + # the advisory lock key space. + run: | + filter='package(buzz-db) and test(/channel_head_checked_/)' + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + --test-threads=1 \ + --no-tests=fail \ + -E "${filter}" \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Canvas CAS ingest-path wiring test + # End-to-end proof that the expected-revision parser → CAS dispatch → + # insert_channel_head_checked DB transaction round-trip is wired through + # ingest_event_inner. Steps: first write (ExpectNoHead → Inserted), advance + # (ExpectedHead match → Inserted), stale competitor (ExpectedHead mismatch + # → conflict: rejection), loser absent from DB, untagged write still + # appends unconditionally via the generic path. + # + # Mutation oracle: removing the `canvas_revision_spec` dispatch block from + # ingest_event_inner (the `} else if let Some(spec) = canvas_revision_spec` + # branch) makes the stale step return Ok instead of a conflict: rejection, + # failing the assert!. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + --no-tests=fail \ + -E 'package(buzz-relay) and test(=handlers::ingest::tests::canvas_cas_dispatch_wired_through_ingest_event_inner)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + REDIS_URL: redis://localhost:6379 - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index f1d2c7f4e67..fe65d462838 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -3235,7 +3235,6 @@ mod set_canvas_tests { use super::cmd_set_canvas; use crate::client::BuzzClient; - use crate::CliError; const CHANNEL: &str = "326d56bc-c96c-4af0-86a1-5e804cd1b467"; diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index ceb22639e05..1a4ad1d278e 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -1528,26 +1528,20 @@ pub async fn insert_event_with_thread_metadata( } /// Outcome of a channel-head conditional canvas write. -/// -/// Canvas events (kind 40100) are plain appends that accrue as version history; -/// the "current" canvas is the live head under `created_at DESC, id ASC`. When a -/// write carries an `expected-revision` precondition, this enum reports whether -/// it matched. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ChannelHeadWriteStatus { /// The event was appended as the new channel head. Inserted, /// The exact event already existed (idempotent replay of the current head). Duplicate, - /// `ExpectedHead` was required but no live head exists for the channel/kind. + /// `ExpectedHead` was supplied but no live head exists for the channel. RevisionMissing, - /// The live head differs from the required `ExpectedHead` (or `ExpectNoHead` - /// was required but a head already exists). + /// The live head id did not match `ExpectedHead`, or `ExpectNoHead` was + /// required but a head already exists. RevisionMismatch, - /// The precondition matched, but the candidate does not sort strictly ahead - /// of the current head under `created_at DESC, id ASC`, so accepting it - /// would leave the visible canvas unchanged. Rejected to preserve the - /// invariant that an accepted tagged write IS the new head. + /// Precondition matched but the candidate does not sort ahead of the head + /// under `created_at DESC, id ASC`; accepting it would not change the + /// visible canvas. SupersedeFailed, } @@ -1560,13 +1554,8 @@ pub enum ChannelHeadPrecondition<'a> { ExpectedHead(&'a [u8]), } -/// Whether a candidate channel-head event sorts strictly ahead of the current -/// head under the canonical `created_at DESC, id ASC` ordering — i.e. whether -/// accepting it actually makes it the new head. -/// -/// The head is the row with the greatest `created_at`, ties broken by the -/// lowest `id`. So the candidate wins iff it has a later `created_at`, or an -/// equal `created_at` with a strictly lower `id`. +/// Returns `true` iff a candidate canvas event sorts strictly ahead of the +/// current head under `created_at DESC, id ASC`. fn candidate_supersedes_head( candidate: &Event, candidate_id: &[u8; 32], @@ -1585,32 +1574,16 @@ fn candidate_supersedes_head( /// Conditionally append a channel-head event (canvas kind 40100) under an /// optimistic-concurrency precondition. /// -/// The check and the insert share one advisory lock keyed on -/// `(community, kind, channel)` — deliberately excluding the author, since any -/// channel member edits the same canvas and cross-author concurrent edits must -/// serialize on the same head. The head is read as `created_at DESC, id ASC` -/// (matching the read path), the precondition is evaluated, and on success the -/// event plus its mentions are inserted in the same transaction. Precondition -/// failures mutate nothing. -/// -/// # Head-advancement guarantee -/// -/// A matching `ExpectedHead` precondition additionally requires the candidate -/// to sort strictly ahead of the current head under `created_at DESC, id ASC`. -/// Otherwise the write would be accepted and fanned out yet leave the visible -/// head unchanged — a same-second lower-id or behind-clock writer would -/// "succeed" without restoring the selected content or advancing the canvas. -/// Such writes reject as [`ChannelHeadWriteStatus::SupersedeFailed`]. First-party -/// writers sign `created_at = max(now, head.created_at + 1)`, so this reject is -/// practically unreachable outside clock pathology. -/// -/// # Idempotent replay exception +/// Acquires a per-`(community, kind, channel)` advisory lock (author excluded +/// so cross-author concurrent edits serialize on the same head), reads the head +/// under `created_at DESC, id ASC`, evaluates the precondition, and on success +/// inserts the event and its mentions in the same transaction. Failures are +/// pure reads — nothing is mutated. /// -/// Re-submitting the byte-identical event that already is the live head returns -/// [`ChannelHeadWriteStatus::Duplicate`] without evaluating the supplied -/// precondition. A duplicate insert cannot change state, so evaluating its -/// precondition buys nothing and would turn a safe transport retry (identical -/// bytes replayed after a lost response) into a false conflict. +/// A matching `ExpectedHead` precondition also requires the candidate to sort +/// strictly ahead of the head; if not, returns `SupersedeFailed`. Re-submitting +/// the byte-identical live head short-circuits to `Duplicate` without evaluating +/// the precondition (safe transport-retry semantics). pub async fn insert_channel_head_checked( pool: &PgPool, community_id: CommunityId, @@ -1626,9 +1599,7 @@ pub async fn insert_channel_head_checked( let mut tx = pool.begin().await?; - // Serialize check+insert per (community, kind, channel). The author is - // intentionally excluded from the key so cross-author concurrent edits - // contend on the same lock. + // Serialize check+insert per (community, kind, channel). let lock_key = event_replacement_lock_key( community_id, kind_i32, @@ -1651,9 +1622,7 @@ pub async fn insert_channel_head_checked( .fetch_optional(&mut *tx) .await?; - // Idempotent replay: the incoming event already is the live head. Returns - // before precondition evaluation — a duplicate insert cannot change state, - // so a byte-identical transport retry must never surface as a false conflict. + // Idempotent replay: the incoming event is already the live head. if head .as_ref() .is_some_and(|(id, _)| id.as_slice() == incoming_id.as_slice()) @@ -1677,9 +1646,6 @@ pub async fn insert_channel_head_checked( if id.as_slice() != expected { Some(ChannelHeadWriteStatus::RevisionMismatch) } else if !candidate_supersedes_head(event, incoming_id, *head_created_at, id) { - // Precondition matched, but the candidate cannot become the - // head under `created_at DESC, id ASC`. Accepting it would leave - // the visible canvas unchanged. Some(ChannelHeadWriteStatus::SupersedeFailed) } else { None @@ -3551,7 +3517,7 @@ mod postgres_tests { ChannelHeadPrecondition::ExpectNoHead, ) .await - .map(|(stored, status)| (stored.id.to_vec(), status)) + .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) }); let tb = tokio::spawn(async move { insert_channel_head_checked( @@ -3562,7 +3528,7 @@ mod postgres_tests { ChannelHeadPrecondition::ExpectNoHead, ) .await - .map(|(stored, status)| (stored.id.to_vec(), status)) + .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) }); let (id_a, status_a) = ta.await.expect("join A").expect("call A"); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 0e29be6d12f..a50c269d2aa 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -6042,4 +6042,226 @@ mod postgres_tests { "non-hex value must be rejected", ); } + + // ── CAS ingest-path wiring test ─────────────────────────────────────── + // + // Proves that the `expected-revision` parser → dispatch → DB transaction + // round-trip is wired end-to-end through `ingest_event_inner`. Deletng or + // bypassing the CAS dispatch block (the `} else if let Some(spec) = + // canvas_revision_spec.as_ref() {` branch) must turn this test red. + // + // Mutation oracle for the dispatch: + // - Removing the `canvas_revision_spec` branch makes tagged writes fall + // through to the generic append; the stale-head step no longer returns + // a conflict: rejection, causing the assert! below to fail. + // - Replacing `insert_channel_head_checked` with `insert_event_with_thread_metadata` + // has the same effect — no conflict is surfaced. + // + // Requires Postgres (and does NOT need Redis — the fake replay guard fires + // before any Redis-backed path, and the CAS path never touches Redis). + + /// Build the minimal AppState for an ingest-path CAS test. + /// + /// Replaces the NIP-98 replay guard with an always-fresh stub so that no + /// live Redis is needed. The returned `AppState` is ready for + /// `ingest_event_inner` calls. + async fn build_canvas_ingest_state( + db_url: &str, + pool: &sqlx::PgPool, + ) -> Arc { + use buzz_auth::Nip98ReplayGuard; + use nostr::Keys; + + const FAKE_REDIS_URL: &str = "redis://127.0.0.1:1"; // never contacted + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(FAKE_REDIS_URL) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("deadpool redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(FAKE_REDIS_URL, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let mut config = crate::config::Config::from_env().expect("relay config from env"); + config.database_url = db_url.to_owned(); + config.redis_url = FAKE_REDIS_URL.to_string(); + config.require_relay_membership = false; + + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth_svc = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db.clone(), + redis_pool, + audit, + pubsub, + auth_svc, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + + struct AlwaysFresh; + impl Nip98ReplayGuard for AlwaysFresh { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async { Ok(true) }) + } + } + state.nip98_replay = Arc::new(AlwaysFresh); + Arc::new(state) + } + + /// End-to-end CAS dispatch wiring: a tagged write inserts, a stale same-head + /// competitor returns the exact conflict: rejection, the loser is absent from + /// the DB, and an untagged write still appends unconditionally. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn canvas_cas_dispatch_wired_through_ingest_event_inner() { + use buzz_db::channel::{ChannelType, ChannelVisibility}; + use nostr::{Keys, Kind, Tag, Timestamp}; + + let db_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + let pool = sqlx::PgPool::connect(&db_url).await.expect( + "connect test Postgres — start local Postgres before running ignored ingest tests", + ); + let state = build_canvas_ingest_state(&db_url, &pool).await; + + // Provision a fresh community + channel so each test run is isolated. + let host = format!("canvas-cas-wiring-{}.test", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = TenantContext::resolved(community, &host); + + let channel_id = Uuid::new_v4(); + let creator_keys = Keys::generate(); + state + .db + .create_channel_with_id( + community, + channel_id, + &format!("canvas-cas-wiring-{}", channel_id.simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + creator_keys.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create test channel"); + + let now = chrono::Utc::now().timestamp() as u64; + let channel_uuid_str = channel_id.to_string(); + let tracer: Arc = Arc::new(VecTracer::default()); + + let make_auth = |keys: &Keys| IngestAuth::Http { + pubkey: keys.public_key(), + scopes: vec![Scope::ChannelsWrite], + auth_method: HttpAuthMethod::Nip98, + }; + + // ── Step 1: first write with expected-revision=none → Inserted ──────── + let author = Keys::generate(); + let first = nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# v1") + .custom_created_at(Timestamp::from(now)) + .tags([ + Tag::parse(["h", &channel_uuid_str]).unwrap(), + Tag::parse(["expected-revision", "none"]).unwrap(), + ]) + .sign_with_keys(&author) + .expect("sign first canvas"); + let first_id_hex = first.id.to_hex(); + + ingest_event_inner(&state, &tracer, &tenant, first, make_auth(&author)) + .await + .expect("first canvas write must succeed"); + + // ── Step 2: advance with expected-revision= → Inserted ────── + let second = nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# v2") + .custom_created_at(Timestamp::from(now + 1)) + .tags([ + Tag::parse(["h", &channel_uuid_str]).unwrap(), + Tag::parse(["expected-revision", &first_id_hex]).unwrap(), + ]) + .sign_with_keys(&author) + .expect("sign second canvas"); + + ingest_event_inner(&state, &tracer, &tenant, second, make_auth(&author)) + .await + .expect("second canvas write must succeed"); + + // ── Step 3: stale competitor — same first-id precondition → conflict ── + // The head is now the second event, so expected-revision= is stale. + // Mutation oracle: deleting the `canvas_revision_spec` dispatch block makes + // this return Ok instead of the conflict: rejection below. + let stale = nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# stale") + .custom_created_at(Timestamp::from(now + 2)) + .tags([ + Tag::parse(["h", &channel_uuid_str]).unwrap(), + Tag::parse(["expected-revision", &first_id_hex]).unwrap(), + ]) + .sign_with_keys(&author) + .expect("sign stale canvas"); + let stale_id_bytes = stale.id.as_bytes().to_vec(); + + let err = + match ingest_event_inner(&state, &tracer, &tenant, stale, make_auth(&author)).await { + Ok(_) => panic!("stale precondition must be rejected, but ingest returned Ok"), + Err(e) => e, + }; + assert!( + matches!(&err, IngestError::Rejected(msg) if msg.starts_with("conflict:")), + "stale write must return a conflict: rejection; got {:?}", + err, + ); + + // The losing write must not be persisted. + let persisted: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(stale_id_bytes.as_slice()) + .fetch_one(&pool) + .await + .expect("count stale canvas row"); + assert_eq!(persisted, 0, "losing CAS write must not be stored"); + + // ── Step 4: untagged write still appends unconditionally ────────────── + // No expected-revision tag → the event is routed through the generic + // append path, NOT through insert_channel_head_checked. It must succeed + // regardless of the current head state. + let untagged = + nostr::EventBuilder::new(Kind::Custom(KIND_CANVAS as u16), "# unconditional") + .custom_created_at(Timestamp::from(now + 3)) + .tags([Tag::parse(["h", &channel_uuid_str]).unwrap()]) + .sign_with_keys(&author) + .expect("sign untagged canvas"); + + ingest_event_inner(&state, &tracer, &tenant, untagged, make_auth(&author)) + .await + .expect("untagged canvas write must append unconditionally"); + } } From c04f54a611c579e223435562de126e6bca3c4d90 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Mon, 31 Aug 2026 16:23:16 -0400 Subject: [PATCH 29/44] fix(canvas): close tombstone false-success, deterministic lock evidence, and full serialization boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserve Duplicate exclusively for a byte-identical candidate already proven to be the canonical live head. On a post-insert conflict in insert_channel_head_checked, return RevisionMismatch under the held advisory lock — the idempotent-replay branch above already handled the live-head case, so any conflict here is definitively non-live. Eliminates the H → A → soft- delete A → replay A-on-H false success path Thufir reproduced. Replace the caller-supplied canvas_channel_id hint in soft_delete_event_and_update_thread with internal derivation. The function now reads the target event's kind and channel_id from the database inside its own transaction and conditionally acquires the advisory lock for kind-40100 events. Callers cannot bypass the serialization invariant. Serialize every kind-40100 live-head mutator on the same (community, kind, channel) advisory key. insert_event_with_thread_metadata acquires the key for kind-40100 writes with a channel_id so untagged unconditional appends cannot interleave with concurrent tagged read-check-insert sequences. soft_delete_event_and_update_thread derives target kind/channel and acquires the same key for kind-40100 deletes. Replace the two scheduler-dependent concurrent tests with deterministic equivalents using an external lock-holder that queues both writers before releasing. Add channel_head_checked_deleted_replay_returns_revision_mismatch as a direct regression for the tombstone path. Add channel_head_untagged_canvas_append_serializes_on_advisory_key and channel_head_canvas_deletion_serializes_on_advisory_key using the is_finished() blocker pattern to prove both mutators acquire the key. Mutation reds confirmed for all four oracles. Update CI filter to select the two new serialization tests; bump Canvas CAS DB test count from 12 to 13. Update stale contract comments in buzz-sdk builders.rs, buzz-cli channels.rs, and desktop events.rs to describe authoritative relay CAS semantics; remove residual phase-2/advisory/no-relay-enforcement language. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/_ci-relay.yml | 32 +- crates/buzz-cli/src/commands/channels.rs | 10 +- crates/buzz-db/src/store/event.rs | 473 +++++++++++++++++++++-- crates/buzz-sdk/src/builders.rs | 21 +- desktop/src-tauri/src/events.rs | 11 +- 5 files changed, 491 insertions(+), 56 deletions(-) diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 86f54e7ad15..08a7d022515 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -594,9 +594,11 @@ jobs: REDIS_URL: redis://localhost:6379 - name: Canvas CAS DB tests # Transaction, advisory-lock, and precondition-evaluation coverage for the - # kind-40100 atomic compare-and-insert path in buzz-db. All 12 tests share - # the `channel_head_checked_` prefix; a typo in the filter exits with code 4 - # (nextest --no-tests=fail default) so silent zero-test runs are impossible. + # kind-40100 atomic compare-and-insert path in buzz-db. 13 tests share the + # `channel_head_checked_` prefix; two additional serialization tests cover + # untagged canvas appends and canvas soft-deletions. A typo in either filter + # exits with code 4 (nextest --no-tests=fail default) so silent zero-test + # runs are impossible. # # Covers: first-create (ExpectNoHead → Inserted), first-create rejection # when head exists (ExpectNoHead → RevisionMismatch + loser absent), @@ -605,23 +607,33 @@ jobs: # but no head → RevisionMissing), idempotent replay (Duplicate short-circuit # pre-precondition), replay-skips-stale-precondition, same-second higher-id # SupersedeFailed + loser absent, same-second lower-id Inserted, behind-clock - # SupersedeFailed, concurrent-authors advisory-lock serialization, and - # concurrent-first-create race. + # SupersedeFailed, tombstone regression (deleted-replay → RevisionMismatch, + # not Duplicate), concurrent-authors advisory-lock serialization with + # deterministic external-blocker oracle, concurrent-first-create race with + # external-blocker oracle, untagged canvas append serialization, and canvas + # soft-delete serialization. # - # Mutation oracle: removing `pg_advisory_xact_lock` from - # `insert_channel_head_checked` causes the concurrent tests to fail under - # load (both writers observe the same head). + # Mutation oracles: + # - post-insert conflict returning Duplicate unconditionally fails + # channel_head_checked_deleted_replay_returns_revision_mismatch. + # - removing pg_advisory_xact_lock from insert_channel_head_checked lets + # both writers read the same head simultaneously: head_count becomes 3 + # (same-head test) or 2 (first-create test) and both return Inserted. + # - removing the lock from insert_event_with_thread_metadata or + # soft_delete_event_and_update_thread for kind-40100 makes + # pg_try_advisory_xact_lock return true under the hold. # # --test-threads=1: the concurrent tests spawn Tokio tasks against a # shared pool; running them in isolation avoids cross-test contention on # the advisory lock key space. run: | - filter='package(buzz-db) and test(/channel_head_checked_/)' + filter1='package(buzz-db) and test(/channel_head_checked_/)' + filter2='package(buzz-db) and test(/channel_head_(untagged_canvas_append|canvas_deletion)_serializes_on_advisory_key/)' cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ --test-threads=1 \ --no-tests=fail \ - -E "${filter}" \ + -E "${filter1} or ${filter2}" \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index fe65d462838..756e55287e7 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -386,14 +386,16 @@ pub async fn cmd_canvas_history( /// /// The target revision is fetched by an ID-scoped query (resolves any age) and /// the head by a separate `limit: 1` query — neither scans the full stream. -/// Fetching the head immediately before the write is the advisory -/// concurrency check: a missing target revision or absent head fails here, and +/// Fetching the head immediately before the write is an optimistic +/// precondition check: a missing target revision or absent head fails here, and /// restoring the current head is short-circuited so history never grows a /// redundant revision. The republished event is built via /// [`buzz_sdk::build_set_canvas_after_head`], which applies writer discipline /// (`created_at = max(now, head.created_at + 1)`) so the restore sorts strictly /// ahead of the head it read; the `expected-revision` tag it carries is -/// advisory (no relay enforcement). +/// enforced by the relay as a CAS (compare-and-swap): the relay reads the +/// canonical live head under an advisory lock and rejects writes whose +/// precondition no longer matches. /// /// After publishing, a single post-write re-read classifies whether the restore /// still holds the visible head (or a later write legitimately built on it). If @@ -402,8 +404,6 @@ pub async fn cmd_canvas_history( /// in history. If the verification read itself fails, the accepted restore is /// reported as success (exit 0) with a stderr warning that verification was /// unavailable — a failed read must never masquerade as a failed restore. -/// Detection is bounded to competitors visible by verification time; -/// preventing the race entirely requires relay-side linearization (phase 2). pub async fn cmd_restore_canvas( client: &BuzzClient, channel_id: &str, diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 1a4ad1d278e..b1188429b73 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -10,8 +10,8 @@ use sqlx::{PgConnection, PgPool, Postgres, QueryBuilder, Row, Transaction}; use uuid::Uuid; use buzz_core::kind::{ - event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, - KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, + event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_CANVAS, + KIND_EVENT_REMINDER, KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, }; use buzz_core::{CommunityId, StoredEvent}; use buzz_datastore_tracing::datastore_span; @@ -1001,6 +1001,14 @@ pub async fn soft_delete_by_coordinate( /// Wraps the delete + counter update in a single transaction so a crash between /// them cannot leave counters permanently inflated. Returns `Ok(true)` if the /// event was deleted this call. +/// +/// When the target event is a kind-40100 (canvas) event, this function derives +/// the target's `kind` and `channel_id` from the database inside the same +/// transaction and acquires the same `(community, kind, channel)` advisory lock +/// used by [`insert_channel_head_checked`] before the UPDATE. This prevents a +/// concurrent tagged write from observing a head that is simultaneously being +/// removed. The serialization invariant is owned entirely by this function; +/// callers do not classify the target kind. pub async fn soft_delete_event_and_update_thread( pool: &PgPool, community_id: CommunityId, @@ -1008,12 +1016,39 @@ pub async fn soft_delete_event_and_update_thread( parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { + use crate::store::replaceable::event_replacement_lock_key; + let connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::EventWrite, ) .await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + // Derive the target event's kind and channel_id inside the transaction so + // that the serialization decision cannot be bypassed by any caller. + let target: Option<(i32, Option)> = sqlx::query_as( + "SELECT kind, channel_id FROM events \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community_id.as_uuid()) + .bind(event_id) + .fetch_optional(&mut *tx) + .await?; + + if let Some((kind, Some(channel_id))) = target { + if kind == KIND_CANVAS as i32 { + let lock_key = event_replacement_lock_key( + community_id, + kind, + &[], + Some(channel_id.as_bytes().as_slice()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + } + } let result = sqlx::query( "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", @@ -1506,6 +1541,13 @@ pub(crate) async fn insert_event_with_thread_metadata_tx( /// inconsistent if one succeeded and the other failed. Keep this as one /// transaction so reply metadata and counters commit together with the event. /// +/// For kind-40100 (canvas) events with a `channel_id`, acquires the same +/// `(community, kind, channel)` advisory lock used by +/// [`insert_channel_head_checked`] so that untagged unconditional canvas appends +/// serialize against concurrent tagged writes on the same coordinate. Untagged +/// writes remain unconditional — they never conflict — but must not race the +/// head read inside a concurrent tagged transaction. +/// /// Returns `(StoredEvent, was_inserted)`. pub async fn insert_event_with_thread_metadata( pool: &PgPool, @@ -1514,12 +1556,30 @@ pub async fn insert_event_with_thread_metadata( channel_id: Option, thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { + use crate::store::replaceable::event_replacement_lock_key; + let connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::EventWrite, ) .await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + + if event_kind_i32(event) == KIND_CANVAS as i32 { + if let Some(ch) = channel_id { + let lock_key = event_replacement_lock_key( + community_id, + KIND_CANVAS as i32, + &[], + Some(ch.as_bytes().as_slice()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + } + } + let result = insert_event_with_thread_metadata_tx(&mut tx, community_id, event, channel_id, thread_meta) .await?; @@ -1664,10 +1724,16 @@ pub async fn insert_channel_head_checked( insert_event_with_thread_metadata_tx(&mut tx, community_id, event, Some(channel_id), None) .await?; if !was_inserted { - // Lost an insert race after passing the precondition (another writer - // committed the identical id). Treat as an idempotent duplicate. + // The primary-key row already exists. The idempotent-replay branch above + // already returned `Duplicate` for the case where the incoming event is + // the canonical live head. Reaching here means the row exists but is NOT + // the live head (e.g. soft-deleted). Because every kind-40100 mutator + // serializes on this advisory key, no concurrent writer can have changed + // the live head since our read above, so the truthful result is + // `RevisionMismatch` — a false `Duplicate` would acknowledge a write + // that did not become live. tx.rollback().await?; - return Ok((stored, ChannelHeadWriteStatus::Duplicate)); + return Ok((stored, ChannelHeadWriteStatus::RevisionMismatch)); } crate::insert_mentions_in_transaction(&mut tx, community_id, event, Some(channel_id)).await?; tx.commit().await?; @@ -3406,16 +3472,150 @@ mod postgres_tests { .expect("behind-clock edit"); assert_eq!(status, ChannelHeadWriteStatus::SupersedeFailed); } + /// Derives the advisory-lock key for a canvas coordinate, matching the key + /// computed inside `insert_channel_head_checked` and + /// `soft_delete_event_and_update_thread`. + fn canvas_lock_key(community: CommunityId, channel: Uuid) -> i64 { + crate::store::replaceable::event_replacement_lock_key( + community, + buzz_core::kind::KIND_CANVAS as i32, + &[], + Some(channel.as_bytes().as_slice()), + ) + } + + /// Tombstone regression: H → A → soft-delete A → replay A-on-H must return + /// `RevisionMismatch`. A must remain deleted and H must remain the live head. + /// + /// Mutation oracle: the post-insert conflict branch returning `Duplicate` + /// unconditionally makes this test fail with status `Duplicate` instead of + /// `RevisionMismatch`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_checked_deleted_replay_returns_revision_mismatch() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + + // H: seed the initial head. + let head = make_canvas_event_at("# Head", 1000); + insert_channel_head_checked( + &pool, + community, + &head, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("seed head H"); + let head_id = head.id.to_bytes().to_vec(); + + // A: insert a second revision. + let a = make_canvas_event_at("# A", 1001); + insert_channel_head_checked( + &pool, + community, + &a, + channel, + ChannelHeadPrecondition::ExpectedHead(&head_id), + ) + .await + .expect("insert A"); + let a_id = a.id.to_bytes().to_vec(); + + // Soft-delete A so H becomes the live head again. + soft_delete_event_and_update_thread(&pool, community, &a_id, None, None) + .await + .expect("soft-delete A"); + + // Verify A is deleted and H is the live head before replay. + let a_deleted: Option = sqlx::query_scalar( + "SELECT deleted_at IS NOT NULL FROM events \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(&a_id) + .fetch_optional(&pool) + .await + .expect("check A deleted"); + assert_eq!( + a_deleted, + Some(true), + "A must be soft-deleted before replay" + ); + + // Replay byte-identical A against live head H — must not return Duplicate. + let (_, status) = insert_channel_head_checked( + &pool, + community, + &a, + channel, + ChannelHeadPrecondition::ExpectedHead(&head_id), + ) + .await + .expect("replay A"); + assert_eq!( + status, + ChannelHeadWriteStatus::RevisionMismatch, + "replay of a deleted event must return RevisionMismatch, not Duplicate" + ); + + // A must still be deleted after the replay attempt. + let a_still_deleted: Option = sqlx::query_scalar( + "SELECT deleted_at IS NOT NULL FROM events \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(&a_id) + .fetch_optional(&pool) + .await + .expect("check A still deleted"); + assert_eq!( + a_still_deleted, + Some(true), + "A must remain deleted after replay" + ); + + // H must remain the canonical live head. + let live_head: Vec = sqlx::query_scalar( + "SELECT id FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 \ + AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("read live head"); + assert_eq!( + live_head, head_id, + "H must remain the live head after failed replay" + ); + } /// Race soundness: two concurrent authors both assert the same live head and /// both sign strictly-advancing writes. The per-(community, channel) advisory /// lock must serialize check+insert so exactly one wins (`Inserted`) and the /// other observes the moved head (`RevisionMismatch`). Exactly one write may - /// become the visible head. + /// become the visible head; the loser must not appear as a live row. + /// + /// Causal oracle: an external connection holds the exact advisory key before + /// either writer starts. Both writers queue at `pg_advisory_xact_lock` and + /// are released together when the blocker rolls back. This guarantees both + /// transactions are open and competing when they unblock, making the + /// interleaving deterministic rather than scheduler-dependent. Without the + /// lock in `insert_channel_head_checked`, the tasks proceed without waiting; + /// `channel_head_untagged_canvas_append_serializes_on_advisory_key` proves + /// the key is exclusively held during an in-flight write (direct lock-contention + /// oracle), and this test proves the end-state invariant holds. /// - /// Mutation oracle: removing the `pg_advisory_xact_lock` call from - /// `insert_channel_head_checked` causes this test to fail under concurrent - /// load (both writers can observe the same head simultaneously). + /// Mutation oracle: removing `pg_advisory_xact_lock` from + /// `insert_channel_head_checked` means the blocker no longer holds both writers; + /// they race concurrently — both can read the same head, both insert (distinct + /// event IDs → no PK conflict), both return `Inserted`, and `head_count` + /// becomes 3 instead of 2. The `assert_eq!(head_count, 2)` fails. #[tokio::test] #[ignore = "requires Postgres"] async fn channel_head_checked_concurrent_authors_only_one_advances() { @@ -3435,6 +3635,16 @@ mod postgres_tests { .expect("seed base head"); let base_id = base.id.to_bytes().to_vec(); + // Acquire the exact advisory key from an external connection so both + // writers block immediately at pg_advisory_xact_lock. + let lock_key = canvas_lock_key(community, channel); + let mut blocker = pool.begin().await.expect("blocker tx"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *blocker) + .await + .expect("blocker acquires key"); + // Both edits assert `base` as their head and are timestamped strictly // ahead of it (writer discipline), so neither trips SupersedeFailed — // only the advisory-lock serialization decides the winner. @@ -3452,7 +3662,7 @@ mod postgres_tests { ChannelHeadPrecondition::ExpectedHead(&id_a), ) .await - .map(|(_, status)| status) + .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) }); let tb = tokio::spawn(async move { insert_channel_head_checked( @@ -3463,22 +3673,26 @@ mod postgres_tests { ChannelHeadPrecondition::ExpectedHead(&id_b), ) .await - .map(|(_, status)| status) + .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) }); - let status_a = ta.await.expect("join A").expect("insert A"); - let status_b = tb.await.expect("join B").expect("insert B"); + // Give both tasks time to open their transactions and queue at the key. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let mut statuses = [status_a, status_b]; - statuses.sort_by_key(|s| format!("{s:?}")); - assert_eq!( - statuses, - [ - ChannelHeadWriteStatus::Inserted, - ChannelHeadWriteStatus::RevisionMismatch - ], - "exactly one concurrent write advances the head; got {statuses:?}" - ); + // Release — both writers unblock and race for the advisory lock. + blocker.rollback().await.expect("release blocker"); + + let (id_a, status_a) = ta.await.expect("join A").expect("call A"); + let (id_b, status_b) = tb.await.expect("join B").expect("call B"); + + let (winner_id, loser_id) = if status_a == ChannelHeadWriteStatus::Inserted { + assert_eq!(status_b, ChannelHeadWriteStatus::RevisionMismatch); + (id_a, id_b) + } else { + assert_eq!(status_a, ChannelHeadWriteStatus::RevisionMismatch); + assert_eq!(status_b, ChannelHeadWriteStatus::Inserted); + (id_b, id_a) + }; // Exactly one new canvas row (beyond the base) was committed. let head_count: i64 = sqlx::query_scalar( @@ -3492,11 +3706,51 @@ mod postgres_tests { .await .expect("count committed canvas rows"); assert_eq!(head_count, 2, "base plus exactly one winning edit"); + + // The canonical live head must be the winner's event. + let live_head: Vec = sqlx::query_scalar( + "SELECT id FROM events \ + WHERE community_id = $1 AND kind = $2 AND channel_id = $3 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_CANVAS as i32) + .bind(channel) + .fetch_one(&pool) + .await + .expect("read live head"); + assert_eq!( + live_head, winner_id, + "live head must be the Inserted writer's event" + ); + + // The losing writer's event must not be present as a live row. + let loser_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(&loser_id) + .fetch_one(&pool) + .await + .expect("check loser absent"); + assert_eq!( + loser_count, 0, + "the losing writer's event must not be a live row" + ); } /// Race soundness for first-create: two writers both assert `ExpectNoHead` /// simultaneously. Exactly one must be `Inserted`; the other gets /// `RevisionMismatch`. The loser must not be persisted. + /// + /// Causal oracle: same external-blocker pattern as the same-head race test. + /// Both writers are queued before the blocker releases, making the outcome + /// deterministic regardless of scheduler ordering. + /// + /// Mutation oracle: removing `pg_advisory_xact_lock` from + /// `insert_channel_head_checked` lets both writers read `None` for the head, + /// both pass `ExpectNoHead`, both insert, and `head_count` becomes 2. #[tokio::test] #[ignore = "requires Postgres"] async fn channel_head_checked_concurrent_first_create_only_one_wins() { @@ -3504,6 +3758,15 @@ mod postgres_tests { let community = CommunityId::from_uuid(make_test_community(&pool).await); let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + // Acquire the exact advisory key before either writer starts. + let lock_key = canvas_lock_key(community, channel); + let mut blocker = pool.begin().await.expect("blocker tx"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *blocker) + .await + .expect("blocker acquires key"); + let a = make_canvas_event_at("# First A", 1000); let b = make_canvas_event_at("# First B", 1001); @@ -3531,6 +3794,12 @@ mod postgres_tests { .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) }); + // Give both tasks time to open their transactions and queue at the key. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // Release — both race for the advisory lock. + blocker.rollback().await.expect("release blocker"); + let (id_a, status_a) = ta.await.expect("join A").expect("call A"); let (id_b, status_b) = tb.await.expect("join B").expect("call B"); @@ -3570,4 +3839,162 @@ mod postgres_tests { .expect("read head"); assert_eq!(head_id, inserted, "stored head must be the winner"); } + + /// Serialization coverage: an untagged kind-40100 unconditional append must + /// acquire the same `(community, kind, channel)` advisory key as a tagged + /// write so the two cannot interleave on the head read. + /// + /// Proof: an external connection holds the advisory key; the untagged write + /// task is spawned. Since the write acquires the same key, it blocks while + /// the holder has it — `JoinHandle::is_finished()` returns false. After the + /// holder releases, the task completes and the row is committed. + /// + /// Mutation oracle: removing the `pg_advisory_xact_lock` block from + /// `insert_event_with_thread_metadata` for kind-40100 lets the write proceed + /// without acquiring the key. The task completes immediately (no block), so + /// `is_finished()` returns true while the holder still has the key — + /// `assert!(!write_task.is_finished())` fails. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_untagged_canvas_append_serializes_on_advisory_key() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let lock_key = canvas_lock_key(community, channel); + + // Hold the exact advisory key on a dedicated connection. + let mut holder = pool.begin().await.expect("holder tx"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *holder) + .await + .expect("holder acquires key"); + + // Spawn the untagged write — it should block at pg_advisory_xact_lock. + let event = make_canvas_event_at("# Untagged", 1000); + let pool_write = pool.clone(); + let write_task = tokio::spawn(async move { + insert_event_with_thread_metadata(&pool_write, community, &event, Some(channel), None) + .await + }); + + // Give the write task time to open its transaction and reach the lock. + // The runtime drives the task until it blocks (advisory-lock wait suspends + // the async task back to the executor). + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // The write task must NOT have finished while the holder has the key. + assert!( + !write_task.is_finished(), + "untagged canvas write must block on the advisory key while holder has it; \ + the task finished immediately, meaning the lock was not acquired" + ); + + // Release the holder — the blocked write can now acquire the key. + holder.rollback().await.expect("release holder"); + + // The write must now complete successfully. + let (stored, was_inserted) = write_task + .await + .expect("join write task") + .expect("untagged canvas insert"); + assert!( + was_inserted, + "untagged canvas write must be inserted after holder releases" + ); + let row_count: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(stored.event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count untagged row"); + assert_eq!(row_count, 1, "untagged canvas row must be committed"); + } + + /// Serialization coverage: `soft_delete_event_and_update_thread` for a + /// kind-40100 event must acquire the same advisory key as tagged writes. + /// + /// Proof: an external connection holds the advisory key; the delete task + /// is spawned and blocks while the holder has it — `is_finished()` returns + /// false. After the holder releases, the task completes and the row is + /// soft-deleted. + /// + /// Mutation oracle: removing the advisory-lock branch from + /// `soft_delete_event_and_update_thread` lets the delete bypass the key. + /// The task finishes immediately while the holder still holds the key — + /// `assert!(!delete_task.is_finished())` fails. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_head_canvas_deletion_serializes_on_advisory_key() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let channel = make_test_channel(&pool, community.as_uuid().to_owned(), None).await; + let lock_key = canvas_lock_key(community, channel); + + // Insert a canvas event to delete. + let event = make_canvas_event_at("# To delete", 1000); + insert_channel_head_checked( + &pool, + community, + &event, + channel, + ChannelHeadPrecondition::ExpectNoHead, + ) + .await + .expect("seed canvas for deletion test"); + let event_id = event.id.to_bytes().to_vec(); + + // Hold the exact advisory key on a dedicated connection. + let mut holder = pool.begin().await.expect("holder tx"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *holder) + .await + .expect("holder acquires key"); + + // Spawn the delete task — it should block at pg_advisory_xact_lock. + let pool_del = pool.clone(); + let eid = event_id.clone(); + let delete_task = tokio::spawn(async move { + soft_delete_event_and_update_thread(&pool_del, community, &eid, None, None).await + }); + + // Give the delete task time to open its transaction and reach the lock. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // The delete task must NOT have finished while the holder has the key. + assert!( + !delete_task.is_finished(), + "canvas soft-delete must block on the advisory key while holder has it; \ + the task finished immediately, meaning the lock was not acquired" + ); + + // Release the holder — the blocked delete can now acquire the key. + holder.rollback().await.expect("release holder"); + + // The delete must now complete successfully. + let deleted = delete_task + .await + .expect("join delete task") + .expect("canvas soft-delete"); + assert!( + deleted, + "canvas event must be deleted after holder releases" + ); + + let is_deleted: bool = sqlx::query_scalar( + "SELECT deleted_at IS NOT NULL FROM events \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(&event_id) + .fetch_one(&pool) + .await + .expect("check deleted_at"); + assert!( + is_deleted, + "canvas event must have deleted_at set after soft-delete" + ); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index e20c0f2c1f2..f698598b936 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -557,16 +557,11 @@ pub fn build_custom_emoji_set(emojis: &[CustomEmoji]) -> Result)]) -> bool { let Some((head_id, _)) = revisions.first() else { return false; // no head after an accepted write → conservatively superseded diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 0db5b089efd..7003968998e 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -418,12 +418,11 @@ pub fn build_remove_reaction(reaction_event_id: EventId) -> Result]` -/// tag is attached. The tag is documentary/advisory: today's relay does not -/// enforce it, so optimistic concurrency is checked client-side — before submit -/// (stale-edit) and once after (`canvas_write_survived` supersession detection) -/// in `set_canvas`. Detection is best-effort, not prevention; the residual race -/// needs relay enforcement (phase 2). Omitting the tag preserves the historical -/// unconditional-append behavior. +/// tag is attached. The relay enforces this tag as a compare-and-swap (CAS): +/// it reads the canonical live head under an advisory lock and rejects writes +/// whose precondition no longer matches. Client-side checks (stale-edit before +/// submit and `canvas_write_survived` after) are secondary confirmations. +/// Omitting the tag preserves the historical unconditional-append behavior. pub fn build_set_canvas( channel_id: Uuid, content: &str, From 129d03247368e6768aa12f6aeabf0dbe2a247a45 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Mon, 31 Aug 2026 17:07:53 -0400 Subject: [PATCH 30/44] fix(canvas): deterministic pg_locks waiter oracle and remove soft_delete_event bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 50 ms sleep + is_finished() heuristic in both tagged-write race tests with a pg_locks waiter poll. wait_for_advisory_waiters() queries pg_locks for ungranted advisory-lock waiters matching the exact (classid, objid) pair of the canvas coordinate key. Both writer sessions must appear as waiters before the blocker is released, proving they have opened their transactions and reached pg_advisory_xact_lock — causal evidence independent of scheduler timing. Mutation oracle confirmed: removing pg_advisory_xact_lock from insert_channel_head_checked causes wait_for_advisory_waiters to time out (0 waiters observed) and panic with a clear message. Both race tests fail deterministically across 3/3 runs with the mutant applied. Clean source restored. first-create race test: add explicit loser-ID-absent assertion to match the same-head test's correlation proof. Remove Db::soft_delete_event and its free function crate::event::soft_delete_event. Both had zero production callers at this ref and provided a bypass path that could reach kind-40100 rows without acquiring the canvas coordinate advisory lock. The invariant-owning soft_delete_event_and_update_thread is the only public deletion seam. ci.yml: update mutation oracle comments to describe the pg_locks timeout failure mode and the first-create loser-absent assertion. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/_ci-relay.yml | 14 +-- crates/buzz-db/src/store/event.rs | 158 ++++++++++++++++++------------ 2 files changed, 105 insertions(+), 67 deletions(-) diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 08a7d022515..e3ae5b3fcf3 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -609,16 +609,18 @@ jobs: # SupersedeFailed + loser absent, same-second lower-id Inserted, behind-clock # SupersedeFailed, tombstone regression (deleted-replay → RevisionMismatch, # not Duplicate), concurrent-authors advisory-lock serialization with - # deterministic external-blocker oracle, concurrent-first-create race with - # external-blocker oracle, untagged canvas append serialization, and canvas - # soft-delete serialization. + # deterministic pg_locks waiter oracle, concurrent-first-create race with + # pg_locks waiter oracle + explicit loser-absent assertion, untagged canvas + # append serialization, and canvas soft-delete serialization. # # Mutation oracles: # - post-insert conflict returning Duplicate unconditionally fails # channel_head_checked_deleted_replay_returns_revision_mismatch. - # - removing pg_advisory_xact_lock from insert_channel_head_checked lets - # both writers read the same head simultaneously: head_count becomes 3 - # (same-head test) or 2 (first-create test) and both return Inserted. + # - removing pg_advisory_xact_lock from insert_channel_head_checked means + # writers never queue as pg_locks advisory waiters; wait_for_advisory_waiters + # times out (both race tests fail deterministically), or both writers read + # the same head simultaneously: head_count becomes 3 (same-head test) or 2 + # (first-create test) and both return Inserted. # - removing the lock from insert_event_with_thread_metadata or # soft_delete_event_and_update_thread for kind-40100 makes # pg_try_advisory_xact_lock return true under the hold. diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index b1188429b73..9d781b8d9ab 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -910,32 +910,6 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer Ok(cnt) } -/// Soft-delete an event by setting `deleted_at = NOW()`. -/// -/// Returns `Ok(true)` if the event was deleted, `Ok(false)` if already deleted -/// or not found. Callers are responsible for decrementing thread reply counts -/// when the deleted event is a thread reply. -pub async fn soft_delete_event( - pool: &PgPool, - community_id: CommunityId, - event_id: &[u8], -) -> Result { - let mut connection = crate::observability::acquire_writer( - pool, - crate::observability::WriterOperation::EventWrite, - ) - .await?; - let result = sqlx::query( - "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", - ) - .bind(community_id.as_uuid()) - .bind(event_id) - .execute(&mut *connection) - .await?; - - Ok(result.rows_affected() > 0) -} - /// Soft-delete the live row for an addressable coordinate /// `(kind, pubkey, d_tag)` — the NIP-33 replacement key — provided it is not /// newer than the deletion request. @@ -2156,16 +2130,6 @@ pub async fn insert_reaction_event_with_thread_metadata( .await } - /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. - #[datastore_span(name = "soft_delete_event", system = "postgresql")] - pub async fn soft_delete_event( - &self, - community_id: CommunityId, - event_id: &[u8], - ) -> Result { - crate::event::soft_delete_event(&self.pool, community_id, event_id).await - } - /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` /// when it is not newer than the deletion request. /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; @@ -3484,6 +3448,57 @@ mod postgres_tests { ) } + /// Polls `pg_locks` until at least `min_waiters` sessions are queued as + /// ungranted waiters on the given `int8` advisory lock key, or until + /// `timeout` elapses. + /// + /// Returns `Ok(())` once the condition is met. Panics if the timeout + /// expires before the required number of waiters appear, or if any waiter + /// task completes (exits the lock wait) before the condition is met. + /// + /// Postgres stores `pg_advisory_xact_lock(int8)` rows in `pg_locks` as + /// `(locktype='advisory', classid=(key>>32)::oid, objid=(key & 0xffffffff)::oid)`. + /// The `classid`/`objid` columns are of type `oid` (unsigned 32-bit integer). + async fn wait_for_advisory_waiters( + pool: &PgPool, + lock_key: i64, + min_waiters: i64, + timeout: std::time::Duration, + ) { + let deadline = std::time::Instant::now() + timeout; + // Split the int8 key into its two oid halves exactly as Postgres does. + // Cast each half to int8 first (no overflow), then let sqlx bind them + // as bigint; Postgres compares oid columns via implicit cast. + let classid = ((lock_key as u64) >> 32) as i64; + let objid = ((lock_key as u64) & 0xffff_ffff) as i64; + loop { + let waiters: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_locks \ + WHERE locktype = 'advisory' \ + AND classid = $1::oid \ + AND objid = $2::oid \ + AND NOT granted", + ) + .bind(classid) + .bind(objid) + .fetch_one(pool) + .await + .expect("pg_locks waiter query"); + + if waiters >= min_waiters { + return; + } + + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {min_waiters} advisory waiters on key {lock_key:#x}; \ + only {waiters} appeared — the production lock was likely removed" + ); + + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + } + /// Tombstone regression: H → A → soft-delete A → replay A-on-H must return /// `RevisionMismatch`. A must remain deleted and H must remain the live head. /// @@ -3602,20 +3617,17 @@ mod postgres_tests { /// become the visible head; the loser must not appear as a live row. /// /// Causal oracle: an external connection holds the exact advisory key before - /// either writer starts. Both writers queue at `pg_advisory_xact_lock` and - /// are released together when the blocker rolls back. This guarantees both - /// transactions are open and competing when they unblock, making the - /// interleaving deterministic rather than scheduler-dependent. Without the - /// lock in `insert_channel_head_checked`, the tasks proceed without waiting; - /// `channel_head_untagged_canvas_append_serializes_on_advisory_key` proves - /// the key is exclusively held during an in-flight write (direct lock-contention - /// oracle), and this test proves the end-state invariant holds. + /// either writer starts. Both writers are spawned and `pg_locks` is polled + /// until both appear as ungranted waiters on this exact key. Only then is + /// the blocker released. This is a causal proof: both sessions have opened + /// their transactions and reached `pg_advisory_xact_lock` before the + /// interleaving begins — the outcome is deterministic rather than + /// scheduler-dependent. /// /// Mutation oracle: removing `pg_advisory_xact_lock` from - /// `insert_channel_head_checked` means the blocker no longer holds both writers; - /// they race concurrently — both can read the same head, both insert (distinct - /// event IDs → no PK conflict), both return `Inserted`, and `head_count` - /// becomes 3 instead of 2. The `assert_eq!(head_count, 2)` fails. + /// `insert_channel_head_checked` means neither writer queues as a waiter; + /// `wait_for_advisory_waiters` times out, or both writers read the same head, + /// both insert, and `head_count` becomes 3 instead of 2. #[tokio::test] #[ignore = "requires Postgres"] async fn channel_head_checked_concurrent_authors_only_one_advances() { @@ -3676,8 +3688,11 @@ mod postgres_tests { .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) }); - // Give both tasks time to open their transactions and queue at the key. - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + // Wait until both writers are observed as ungranted advisory-lock + // waiters in pg_locks. This is a causal proof that both sessions have + // opened their transactions and are blocked at pg_advisory_xact_lock + // before we release the blocker. + wait_for_advisory_waiters(&pool, lock_key, 2, std::time::Duration::from_secs(5)).await; // Release — both writers unblock and race for the advisory lock. blocker.rollback().await.expect("release blocker"); @@ -3744,13 +3759,16 @@ mod postgres_tests { /// simultaneously. Exactly one must be `Inserted`; the other gets /// `RevisionMismatch`. The loser must not be persisted. /// - /// Causal oracle: same external-blocker pattern as the same-head race test. - /// Both writers are queued before the blocker releases, making the outcome - /// deterministic regardless of scheduler ordering. + /// Causal oracle: an external connection holds the exact advisory key before + /// either writer starts. Both writers are spawned and `pg_locks` is polled + /// until both appear as ungranted waiters on this exact key, proving both + /// transactions have opened and reached `pg_advisory_xact_lock`. Only then + /// is the blocker released. /// /// Mutation oracle: removing `pg_advisory_xact_lock` from - /// `insert_channel_head_checked` lets both writers read `None` for the head, - /// both pass `ExpectNoHead`, both insert, and `head_count` becomes 2. + /// `insert_channel_head_checked` means neither writer queues as a waiter; + /// `wait_for_advisory_waiters` times out, or both writers read `None` for + /// the head, both pass `ExpectNoHead`, both insert, and `head_count` becomes 2. #[tokio::test] #[ignore = "requires Postgres"] async fn channel_head_checked_concurrent_first_create_only_one_wins() { @@ -3794,8 +3812,11 @@ mod postgres_tests { .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) }); - // Give both tasks time to open their transactions and queue at the key. - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + // Wait until both writers are observed as ungranted advisory-lock + // waiters in pg_locks. This is a causal proof that both sessions have + // opened their transactions and are blocked at pg_advisory_xact_lock + // before we release the blocker. + wait_for_advisory_waiters(&pool, lock_key, 2, std::time::Duration::from_secs(5)).await; // Release — both race for the advisory lock. blocker.rollback().await.expect("release blocker"); @@ -3803,13 +3824,13 @@ mod postgres_tests { let (id_a, status_a) = ta.await.expect("join A").expect("call A"); let (id_b, status_b) = tb.await.expect("join B").expect("call B"); - let inserted = if status_a == ChannelHeadWriteStatus::Inserted { + let (winner_id, loser_id) = if status_a == ChannelHeadWriteStatus::Inserted { assert_eq!(status_b, ChannelHeadWriteStatus::RevisionMismatch); - id_a + (id_a, id_b) } else { assert_eq!(status_a, ChannelHeadWriteStatus::RevisionMismatch); assert_eq!(status_b, ChannelHeadWriteStatus::Inserted); - id_b + (id_b, id_a) }; // Exactly one canvas row exists. @@ -3837,7 +3858,22 @@ mod postgres_tests { .fetch_one(&pool) .await .expect("read head"); - assert_eq!(head_id, inserted, "stored head must be the winner"); + assert_eq!(head_id, winner_id, "stored head must be the winner"); + + // The loser must not appear as a live row. + let loser_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(&loser_id) + .fetch_one(&pool) + .await + .expect("check loser absent"); + assert_eq!( + loser_count, 0, + "the losing first-create writer must not be a live row" + ); } /// Serialization coverage: an untagged kind-40100 unconditional append must From 9e86d813c65121a54e456a1844357135752c68a5 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 11:50:26 -0400 Subject: [PATCH 31/44] fix(canvas): keep accepted-write notice and cached content on refetch failure After a save or restore settles with verified:false, the mutation's onSettled fires invalidation which triggers a background refetch. When that refetch fails the query enters an error+data state: TanStack Query v5 retains the last successful data alongside the new error. The prior unconditional error guards in both components would return the full destructive error branch, replacing the unverified-save/restore notice and unmounting the cached canvas or history panel. Fix: gate the full error return on data === undefined (no cached data, i.e. a genuine first-load failure). When data is defined alongside an error, remain in the normal render path and show a separate non- destructive refresh warning (channel-canvas-refresh-error / channel-canvas-history-refresh-error) which clears when the next refetch succeeds. Add CanvasRefetchErrorRecovery.test.mjs covering: - save verified:false + refetch failure: notice, canvas, and warning all visible; full error state absent - save recovery: warning clears, notice persists after successful refetch - restore verified:false + refetch failure: history panel stays mounted, restore notice and rows visible, both canvas and history warnings shown - initial-error no-data: full error state still fires when no cache exists Reverting either component's data === undefined guard turns the corresponding scenario(s) red. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../channels/ui/CanvasHistoryPanel.tsx | 18 +- .../ui/CanvasRefetchErrorRecovery.test.mjs | 466 ++++++++++++++++++ .../features/channels/ui/ChannelCanvas.tsx | 18 +- 3 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/channels/ui/CanvasRefetchErrorRecovery.test.mjs diff --git a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx index f873bd8132e..d935962f81c 100644 --- a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx +++ b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx @@ -129,7 +129,11 @@ export function CanvasHistoryPanel({ ); } - if (historyQuery.error instanceof Error) { + // An initial load failure with no cached data: surface the full error state. + // A failed background refetch (data is defined, error is also set) must not + // unmount the history panel or clear the restore notice — show a non-destructive + // refresh warning inside the list view instead. + if (historyQuery.error instanceof Error && historyQuery.data === undefined) { return (

+ {historyQuery.error instanceof Error ? ( +

+ {isRelayUnreachableError(historyQuery.error) + ? RELAY_UNREACHABLE_SHORT + : "Couldn't refresh history — showing last known revisions."} +

+ ) : null} {unverifiedRestoreNotice ? (

", { + url: "http://localhost", +}); + +const HEAD = "a".repeat(64); +const OLDER = "b".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let CommunitiesProvider; +let ChannelCanvas; + +// Tracks how many get_canvas calls have been made. Tests flip +// `failRefetches` after the initial load succeeds so that the +// settlement-triggered invalidation refetch fails. +let getCanvasCallCount = 0; +let getCanvasHistoryCallCount = 0; +let failRefetches = false; +let nextSetCanvasVerified = false; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + installRadixDialogGlobals(dom); + + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd) => { + if (cmd === "get_canvas") { + getCanvasCallCount++; + if (failRefetches) { + throw new Error("relay unavailable"); + } + return { content: "hi", event_id: HEAD, updated_at: 1, author: HEAD }; + } + if (cmd === "set_canvas") { + return { + ok: true, + event_id: "e".repeat(64), + verified: nextSetCanvasVerified, + }; + } + if (cmd === "get_canvas_history") { + getCanvasHistoryCallCount++; + if (failRefetches) { + throw new Error("relay unavailable"); + } + return { + revisions: [ + { event_id: HEAD, content: "hi", created_at: 2, author: HEAD }, + { event_id: OLDER, content: "old", created_at: 1, author: HEAD }, + ], + next_cursor: null, + }; + } + if (cmd === "get_users_batch") { + return { profiles: {} }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities" + )); + ({ ChannelCanvas } = await import("./ChannelCanvas.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +async function settle(iterations = 12) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +function makeClient() { + return new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + mutations: { gcTime: 0 }, + }, + }); +} + +async function mountCanvas(queryClient) { + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ), + ); + }); + // Prime the canvas query with one successful fetch. + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + return { container, root }; +} + +// ── Save scenario ───────────────────────────────────────────────────────────── + +test("save: verified:false + refetch failure keeps canvas and save notice mounted", async () => { + failRefetches = false; + nextSetCanvasVerified = false; + getCanvasCallCount = 0; + + const queryClient = makeClient(); + const { container, root } = await mountCanvas(queryClient); + + // Confirm initial canvas rendered. + assert.ok( + container.querySelector("[data-testid='channel-canvas-content']"), + "canvas content renders after initial load", + ); + + // Open editor and save. + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-edit']")), + ); + assert.ok(container.querySelector("[data-testid='channel-canvas-editor']")); + + // Arm refetch failures BEFORE the save (the mutation's onSettled will + // invalidate and trigger a refetch that must fail). + failRefetches = true; + + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + await settle(20); + + // The save notice must still be visible — refetch failure must not clear it. + assert.ok( + container.querySelector("[data-testid='channel-canvas-unverified-notice']"), + "unverified save notice survives a failed settlement refetch", + ); + // The cached canvas must remain mounted. + assert.ok( + container.querySelector("[data-testid='channel-canvas-content']"), + "cached canvas content remains mounted after failed refetch", + ); + // A non-destructive refresh warning must appear. + assert.ok( + container.querySelector("[data-testid='channel-canvas-refresh-error']"), + "refresh warning renders alongside the cached canvas", + ); + // The full-error destructive path must NOT have fired. + assert.equal( + container.querySelector("[role='alert']"), + null, + "the full destructive error state must not render when data is cached", + ); + + failRefetches = false; + await settle(4); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); + +test("save: successful refetch after recovery clears the refresh warning", async () => { + failRefetches = false; + nextSetCanvasVerified = false; + getCanvasCallCount = 0; + + const queryClient = makeClient(); + const { container, root } = await mountCanvas(queryClient); + + await act(async () => + click(container.querySelector("[data-testid='channel-canvas-edit']")), + ); + + failRefetches = true; + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + await settle(20); + assert.ok( + container.querySelector("[data-testid='channel-canvas-refresh-error']"), + "refresh warning present after failed refetch", + ); + assert.ok( + container.querySelector("[data-testid='channel-canvas-unverified-notice']"), + "save notice still visible", + ); + + // Restore connectivity and manually trigger a successful refetch. + failRefetches = false; + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(12); + + assert.equal( + container.querySelector("[data-testid='channel-canvas-refresh-error']"), + null, + "refresh warning clears once refetch succeeds", + ); + // Save notice persists until next edit session (its existing reset boundary). + assert.ok( + container.querySelector("[data-testid='channel-canvas-unverified-notice']"), + "save notice persists after refetch recovery", + ); + + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); + +// ── Restore scenario ────────────────────────────────────────────────────────── + +test("restore: verified:false + refetch failure keeps history panel, restore notice, and rows mounted", async () => { + failRefetches = false; + nextSetCanvasVerified = false; + getCanvasCallCount = 0; + getCanvasHistoryCallCount = 0; + + const queryClient = makeClient(); + const { container, root } = await mountCanvas(queryClient); + + // Open history panel. + await act(async () => + click( + container.querySelector("[data-testid='channel-canvas-history-toggle']"), + ), + ); + // Prime the history query with a successful fetch. + await act(async () => { + await queryClient.refetchQueries({ + queryKey: ["channel-canvas-history"], + }); + }); + await settle(12); + + assert.ok( + container.querySelector("[data-testid='channel-canvas-history']"), + "history panel renders after initial load", + ); + + // Expand the older (non-current) revision. + const items = container.querySelectorAll( + "[data-testid='channel-canvas-history-item'] button", + ); + await act(async () => click(items[items.length - 1])); + await settle(); + assert.ok( + container.querySelector("[data-testid='channel-canvas-restore']"), + "restore button visible for older revision", + ); + + // Arm refetch failures BEFORE the restore mutation fires. + failRefetches = true; + + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-restore']")); + }); + await settle(); + + // Confirm the restore in the dialog. + await act(async () => { + click( + dom.window.document.querySelector( + "[data-testid='channel-canvas-restore-confirm-action']", + ), + ); + }); + await settle(20); + + // History panel must remain mounted — not replaced by error state. + assert.ok( + container.querySelector("[data-testid='channel-canvas-history']"), + "history panel remains mounted after failed settlement refetch", + ); + // Restore notice must be visible. + assert.ok( + container.querySelector( + "[data-testid='channel-canvas-restore-unverified-notice']", + ), + "restore notice survives failed settlement refetch", + ); + // History rows must still be present. + const rows = container.querySelectorAll( + "[data-testid='channel-canvas-history-item']", + ); + assert.ok( + rows.length > 0, + "history rows remain visible after failed refetch", + ); + // A non-destructive history refresh warning must appear. + assert.ok( + container.querySelector( + "[data-testid='channel-canvas-history-refresh-error']", + ), + "history refresh warning renders alongside cached rows", + ); + // Parent canvas must also remain mounted with its own refresh warning. + assert.ok( + container.querySelector("[data-testid='channel-canvas-content']"), + "parent canvas content remains mounted", + ); + assert.ok( + container.querySelector("[data-testid='channel-canvas-refresh-error']"), + "canvas refresh warning renders", + ); + + failRefetches = false; + await settle(4); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); + +// ── Initial-error no-data branches ──────────────────────────────────────────── + +test("canvas: initial load failure with no data renders full error state", async () => { + // Start with refetches failing so the very first load fails. + failRefetches = true; + nextSetCanvasVerified = false; + getCanvasCallCount = 0; + + const queryClient = makeClient(); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ), + ); + }); + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(12); + + // Full destructive error state must render. + assert.ok( + container.querySelector("[role='alert']"), + "full error state renders on initial load failure with no data", + ); + // Non-destructive refresh warning must NOT appear (no cached data to show). + assert.equal( + container.querySelector("[data-testid='channel-canvas-refresh-error']"), + null, + "no refresh warning on initial failure — there is no cached canvas", + ); + + failRefetches = false; + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); diff --git a/desktop/src/features/channels/ui/ChannelCanvas.tsx b/desktop/src/features/channels/ui/ChannelCanvas.tsx index d41d053092c..a2e1e6f76dd 100644 --- a/desktop/src/features/channels/ui/ChannelCanvas.tsx +++ b/desktop/src/features/channels/ui/ChannelCanvas.tsx @@ -121,7 +121,11 @@ export function ChannelCanvas({ ); } - if (canvasQuery.error instanceof Error) { + // An initial load failure with no cached data: surface the full error state. + // A failed background refetch (data is defined, error is also set) must not + // replace the cached canvas and accepted-write notice — show a non-destructive + // refresh warning inline instead. + if (canvasQuery.error instanceof Error && canvasQuery.data === undefined) { return (

+ {canvasQuery.error instanceof Error ? ( +

+ {isRelayUnreachableError(canvasQuery.error) + ? RELAY_UNREACHABLE_SHORT + : "Couldn't refresh canvas — showing last known content."} +

+ ) : null} {unverifiedSaveNotice ? (

Date: Tue, 1 Sep 2026 11:51:27 -0400 Subject: [PATCH 32/44] chore(canvas): remove debug call counters from refetch recovery test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCanvasCallCount and getCanvasHistoryCallCount were scaffolding variables never read by any assertion — remove them. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../ui/CanvasRefetchErrorRecovery.test.mjs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/channels/ui/CanvasRefetchErrorRecovery.test.mjs b/desktop/src/features/channels/ui/CanvasRefetchErrorRecovery.test.mjs index 48b24c242bc..f00236efaf1 100644 --- a/desktop/src/features/channels/ui/CanvasRefetchErrorRecovery.test.mjs +++ b/desktop/src/features/channels/ui/CanvasRefetchErrorRecovery.test.mjs @@ -61,11 +61,9 @@ let ChannelNavigationProvider; let CommunitiesProvider; let ChannelCanvas; -// Tracks how many get_canvas calls have been made. Tests flip -// `failRefetches` after the initial load succeeds so that the -// settlement-triggered invalidation refetch fails. -let getCanvasCallCount = 0; -let getCanvasHistoryCallCount = 0; +// `failRefetches` is flipped to true before a mutation fires so that the +// settlement-triggered invalidation refetch fails, exercising the error+data +// state that the components must handle non-destructively. let failRefetches = false; let nextSetCanvasVerified = false; @@ -92,7 +90,6 @@ before(async () => { dom.window.__TAURI_INTERNALS__ = { invoke: async (cmd) => { if (cmd === "get_canvas") { - getCanvasCallCount++; if (failRefetches) { throw new Error("relay unavailable"); } @@ -106,7 +103,6 @@ before(async () => { }; } if (cmd === "get_canvas_history") { - getCanvasHistoryCallCount++; if (failRefetches) { throw new Error("relay unavailable"); } @@ -202,7 +198,6 @@ async function mountCanvas(queryClient) { test("save: verified:false + refetch failure keeps canvas and save notice mounted", async () => { failRefetches = false; nextSetCanvasVerified = false; - getCanvasCallCount = 0; const queryClient = makeClient(); const { container, root } = await mountCanvas(queryClient); @@ -260,7 +255,6 @@ test("save: verified:false + refetch failure keeps canvas and save notice mounte test("save: successful refetch after recovery clears the refresh warning", async () => { failRefetches = false; nextSetCanvasVerified = false; - getCanvasCallCount = 0; const queryClient = makeClient(); const { container, root } = await mountCanvas(queryClient); @@ -311,8 +305,6 @@ test("save: successful refetch after recovery clears the refresh warning", async test("restore: verified:false + refetch failure keeps history panel, restore notice, and rows mounted", async () => { failRefetches = false; nextSetCanvasVerified = false; - getCanvasCallCount = 0; - getCanvasHistoryCallCount = 0; const queryClient = makeClient(); const { container, root } = await mountCanvas(queryClient); @@ -415,7 +407,6 @@ test("canvas: initial load failure with no data renders full error state", async // Start with refetches failing so the very first load fails. failRefetches = true; nextSetCanvasVerified = false; - getCanvasCallCount = 0; const queryClient = makeClient(); const container = dom.window.document.createElement("div"); From 20f993895dfad2c34f007d5236040c0d68176da9 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 1 Sep 2026 13:00:53 -0400 Subject: [PATCH 33/44] fix(relay): use test_support::database_url() in bridge tests after refactor The origin/main Postgres-test isolation refactor (#6730) extracted the local test DATABASE_URL fallback into crate::test_support::database_url(). The merge conflict resolution in bridge.rs left two raw TEST_DB_URL references behind; replace them with the new helper to restore compilation. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/bridge.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index ab2930faab3..da8bdcb824d 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -4579,8 +4579,8 @@ mod postgres_tests { .build() .expect("current_thread runtime"); - let admin_url = - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + let admin_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| crate::test_support::database_url()); // Create a scratch database and run migrations on it. async fn scratch_db(admin: &PgPool, admin_url: &str, suffix: &str) -> (PgPool, String) { @@ -4682,7 +4682,7 @@ mod postgres_tests { let state = rt.block_on(async { let mut config = crate::config::Config::from_env() .expect("Config::from_env required — set DATABASE_URL, REDIS_URL, etc."); - config.database_url = TEST_DB_URL.to_string(); + config.database_url = crate::test_support::database_url(); config.redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); config.relay_url = "wss://dispatch-test.local".to_string(); From 9235a70b88cf90c0d37904237b1188b57d1b40d0 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 2 Sep 2026 13:28:38 -0400 Subject: [PATCH 34/44] fix(canvas): freeze expectedRevision at restore dialog-open, not confirm-time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this fix the mutation's CAS guard could silently advance past the head the user saw when they clicked Restore. Sequence: 1. User opens the restore confirmation dialog (head = A). 2. A background refetch succeeds and installs head C. 3. User confirms — handleRestore read currentRevision from the live render, submitting expectedRevision: C instead of A. The relay CAS check then allowed a write the user never approved against the current head. Fix: capture {revision, frozenExpectedRevision} together at dialog-open (setConfirmRevision) and pass the frozen value through handleRestore so the mutation always submits the head the user saw. Adds a mounted regression: open confirm at head A, successful refetch installs CONCURRENT head C, confirm — assert set_canvas receives expectedRevision: A. Revert-causality verified: restoring the live currentRevision read turns exactly this test red (4/5 pass, new test fails with actual=CONCURRENT expected=HEAD). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../channels/ui/CanvasHistoryPanel.tsx | 49 +++++--- .../ui/CanvasRefetchErrorRecovery.test.mjs | 118 +++++++++++++++++- 2 files changed, 150 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx index d935962f81c..3d983e53a39 100644 --- a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx +++ b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx @@ -59,10 +59,18 @@ export function CanvasHistoryPanel({ const [selectedId, setSelectedId] = React.useState(null); // Restore rewrites the shared channel canvas for everyone, so it is gated // behind an explicit confirmation identifying the target revision. Holds the - // revision awaiting confirmation; `null` means no dialog is open. Nothing - // mutates until the user confirms. - const [confirmRevision, setConfirmRevision] = - React.useState(null); + // revision awaiting confirmation and the head revision captured at open time; + // `null` means no dialog is open. Nothing mutates until the user confirms. + // + // `frozenExpectedRevision` is snapshotted at dialog-open rather than read + // from the current render at confirm-time. Without this, a background + // refetch that installs a new head between "open" and "confirm" would + // silently submit the newer head as the CAS guard, bypassing the conflict + // check for the user's original intent. + const [confirmRevision, setConfirmRevision] = React.useState<{ + revision: CanvasRevision; + frozenExpectedRevision: string | null; + } | null>(null); // Non-destructive notice shown after a restore the relay accepted but could // not verify (the post-write supersession read failed). The restore is // durable; the note tells the user to check the current canvas if a @@ -102,13 +110,18 @@ export function CanvasHistoryPanel({ return summary?.displayName?.trim() || truncatePubkey(pubkey); } - async function handleRestore(revision: CanvasRevision) { - // Restore is a conflict-checked publish against the live head: if the - // canvas moved since this panel loaded, the save command's advisory check - // fails and we surface the same reload state as a normal save. + async function handleRestore( + revision: CanvasRevision, + frozenExpectedRevision: string | null, + ) { + // Restore is a conflict-checked publish against the head that was live when + // the user opened the confirmation dialog. Using the frozen value rather + // than the current render's `currentRevision` prevents a background refetch + // that lands a new head between "open" and "confirm" from silently advancing + // the CAS guard past the user's decision point. const result = await restoreMutation.mutateAsync({ content: revision.content, - expectedRevision: currentRevision ?? CANVAS_EXPECTED_REVISION_NONE, + expectedRevision: frozenExpectedRevision ?? CANVAS_EXPECTED_REVISION_NONE, }); // The restore was accepted. `verified: false` means the post-write // supersession read failed, not that the restore failed — collapse the @@ -227,7 +240,12 @@ export function CanvasHistoryPanel({