diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913..adb3e82801 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -491,6 +491,10 @@ pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006; pub const KIND_STREAM_REMINDER: u32 = 40007; /// A diff/patch message showing file changes (unified diff format). pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008; +/// A response to an interactive card message (e.g. a to-do item check-off). +/// References the card via an `e` tag and the item via an `item` tag; card +/// state is a client-side fold over these events, latest per (item, pubkey). +pub const KIND_CARD_RESPONSE: u32 = 40009; /// Canvas (shared document) for a channel. pub const KIND_CANVAS: u32 = 40100; /// System message for channel state changes (join, leave, rename, etc.). @@ -707,6 +711,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_REMINDER, KIND_STREAM_MESSAGE_DIFF, + KIND_CARD_RESPONSE, KIND_CANVAS, KIND_SYSTEM_MESSAGE, KIND_CHANNEL_SUMMARY, diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a9cdffcdec..1e078cc25d 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1161,8 +1161,9 @@ mod tests { use std::sync::Arc; use buzz_core::kind::{ - KIND_AGENT_OBSERVER_FRAME, KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, - KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_AGENT_OBSERVER_FRAME, KIND_CANVAS, KIND_CARD_RESPONSE, KIND_FORUM_COMMENT, + KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_DIFF, }; use buzz_core::observer::{ encrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1214,6 +1215,7 @@ mod tests { for kind in [ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_CARD_RESPONSE, KIND_CANVAS, KIND_FORUM_POST, KIND_FORUM_VOTE, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index c711538284..57afb84107 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -14,26 +14,27 @@ use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, - KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, - KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, - KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, - KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_CANVAS, KIND_CARD_RESPONSE, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, + KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, + KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, + KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, + KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, + KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, + KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -253,6 +254,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), @@ -485,6 +487,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_STREAM_MESSAGE_SCHEDULED | KIND_STREAM_REMINDER | KIND_STREAM_MESSAGE_DIFF + | KIND_CARD_RESPONSE | KIND_CANVAS | KIND_FORUM_POST | KIND_FORUM_VOTE @@ -3038,6 +3041,7 @@ mod tests { for kind in [ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_CARD_RESPONSE, KIND_CANVAS, KIND_FORUM_POST, KIND_FORUM_VOTE, @@ -3050,6 +3054,23 @@ mod tests { } } + #[test] + fn card_response_requires_messages_write_scope() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_CARD_RESPONSE, &dummy).unwrap(), + Scope::MessagesWrite, + ); + } + + #[test] + fn card_response_is_channel_scoped_not_global() { + // kind:40009 rides the generic channel pipeline: missing h → rejected, + // membership enforced per h tag, never nulled to global. + assert!(requires_h_channel_scope(KIND_CARD_RESPONSE)); + assert!(!is_global_only_kind(KIND_CARD_RESPONSE)); + } + #[test] fn nip29_admin_kinds_require_h_tags() { for kind in [ @@ -3258,6 +3279,7 @@ mod tests { KIND_NIP29_LEAVE_REQUEST, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_DIFF, + KIND_CARD_RESPONSE, KIND_CANVAS, KIND_FORUM_POST, KIND_FORUM_VOTE, diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 9a139f0377..416b53245c 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1,13 +1,13 @@ -//! Typed event builder functions (38 builders). +//! Typed event builder functions (40 builders). //! //! All functions return `Result`. //! The caller signs: `builder.sign_with_keys(&keys)?`. use buzz_core::{ kind::{ - KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION, - KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, + KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_CARD_RESPONSE, + KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, + KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, @@ -23,7 +23,8 @@ use nostr::{EventBuilder, Kind, Tag}; use uuid::Uuid; use crate::{ - ChannelKind, CustomEmoji, DiffMeta, MemberRole, SdkError, ThreadRef, Visibility, VoteDirection, + ChannelKind, CustomEmoji, DiffMeta, MemberRole, SdkError, ThreadRef, TodoCardItem, Visibility, + VoteDirection, }; /// Parse a tag slice, mapping errors to `SdkError::InvalidTag`. @@ -459,6 +460,96 @@ pub fn build_vote( Ok(EventBuilder::new(Kind::Custom(45002), content).tags(tags)) } +/// Maximum items allowed in a `buzz:todo-card` payload (NIP-TC MVP cap). +pub const MAX_TODO_CARD_ITEMS: usize = 20; + +/// Build a stream message (kind 9) carrying a `buzz:todo-card` v1 sentinel. +/// +/// `prose` is the plaintext fallback rendered by clients without card +/// support; the fenced JSON payload is appended after it. Each assignee is +/// p-tagged so they get mentioned. Check-offs come back as kind:40009 +/// responses referencing the signed message's event id (see NIP-TC). +pub fn build_todo_card_message( + channel_id: Uuid, + prose: &str, + title: Option<&str>, + items: &[TodoCardItem], +) -> Result { + if items.is_empty() || items.len() > MAX_TODO_CARD_ITEMS { + return Err(SdkError::InvalidInput(format!( + "todo card needs 1..={MAX_TODO_CARD_ITEMS} items (got {})", + items.len() + ))); + } + let mut seen_ids = std::collections::HashSet::new(); + for item in items { + if item.id.is_empty() || !seen_ids.insert(item.id.as_str()) { + return Err(SdkError::InvalidInput(format!( + "todo card item ids must be non-empty and unique (got {:?})", + item.id + ))); + } + } + + let mut json_items = Vec::with_capacity(items.len()); + let mut assignees: Vec = Vec::new(); + for item in items { + let mut obj = serde_json::Map::new(); + obj.insert("id".into(), serde_json::json!(item.id)); + obj.insert("text".into(), serde_json::json!(item.text)); + if let Some(assignee) = &item.assignee { + let assignee = check_pubkey_hex(assignee, "assignee")?; + obj.insert("assignee".into(), serde_json::json!(assignee)); + if !assignees.contains(&assignee) { + assignees.push(assignee); + } + } + json_items.push(serde_json::Value::Object(obj)); + } + let mut payload = serde_json::Map::new(); + payload.insert("v".into(), serde_json::json!(1)); + if let Some(title) = title { + payload.insert("title".into(), serde_json::json!(title)); + } + payload.insert("items".into(), serde_json::Value::Array(json_items)); + let json = serde_json::Value::Object(payload).to_string(); + + let content = format!("{prose}\n\n```buzz:todo-card\n{json}\n```"); + check_content(&content, 64 * 1024)?; + + let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; + for assignee in &assignees { + tags.push(tag(&["p", assignee])?); + } + Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) +} + +/// Build a to-do card check-off response (kind 40009, NIP-TC). +/// +/// `done: true` checks the item off; `false` un-checks the signer's own +/// completion. State is folded client-side: the latest response per +/// (item, pubkey) wins. +pub fn build_card_response( + channel_id: Uuid, + card_event_id: nostr::EventId, + item_id: &str, + done: bool, +) -> Result { + if item_id.is_empty() { + return Err(SdkError::InvalidInput("item_id must be non-empty".into())); + } + let tags = vec![ + tag(&["h", &channel_id.to_string()])?, + tag(&["e", &card_event_id.to_hex()])?, + tag(&["item", item_id])?, + ]; + Ok(EventBuilder::new( + Kind::Custom(KIND_CARD_RESPONSE as u16), + serde_json::json!({ "done": done }).to_string(), + ) + .tags(tags)) +} + /// Build a NIP-25 reaction event (kind 7). Emoji max 64 chars. pub fn build_reaction( target_event_id: nostr::EventId, @@ -2239,6 +2330,95 @@ mod tests { }) } + #[test] + fn todo_card_message_round_trips_fenced_payload() { + let cid = uuid(); + let assignee = keys().public_key().to_hex(); + let items = [ + TodoCardItem { + id: "a1".into(), + text: "Tom: flip the flag".into(), + assignee: Some(assignee.clone()), + }, + TodoCardItem { + id: "b2".into(), + text: "Anyone: verify dashboards".into(), + assignee: None, + }, + ]; + let ev = sign( + build_todo_card_message(cid, "Launch checklist:", Some("Launch"), &items).unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 9); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert!(has_tag(&ev, "p", &assignee)); + assert!(ev + .content + .starts_with("Launch checklist:\n\n```buzz:todo-card\n")); + assert!(ev.content.ends_with("\n```")); + + // Round-trip: the fenced JSON parses back to the same card. + let json = ev + .content + .split("```buzz:todo-card\n") + .nth(1) + .and_then(|rest| rest.strip_suffix("\n```")) + .expect("fenced payload"); + let parsed: serde_json::Value = serde_json::from_str(json).expect("valid JSON"); + assert_eq!(parsed["v"], 1); + assert_eq!(parsed["title"], "Launch"); + assert_eq!(parsed["items"][0]["id"], "a1"); + assert_eq!(parsed["items"][0]["assignee"], serde_json::json!(assignee)); + assert_eq!(parsed["items"][1]["id"], "b2"); + assert!(parsed["items"][1].get("assignee").is_none()); + } + + #[test] + fn todo_card_message_rejects_invalid_items() { + let cid = uuid(); + assert!(build_todo_card_message(cid, "p", None, &[]).is_err()); + + let dup = |id: &str| TodoCardItem { + id: id.into(), + text: "x".into(), + assignee: None, + }; + assert!(build_todo_card_message(cid, "p", None, &[dup("a"), dup("a")]).is_err()); + assert!(build_todo_card_message(cid, "p", None, &[dup("")]).is_err()); + + let too_many: Vec = (0..=MAX_TODO_CARD_ITEMS) + .map(|i| dup(&format!("item-{i}"))) + .collect(); + assert!(build_todo_card_message(cid, "p", None, &too_many).is_err()); + + let bad_assignee = [TodoCardItem { + id: "a".into(), + text: "x".into(), + assignee: Some("not-hex".into()), + }]; + assert!(build_todo_card_message(cid, "p", None, &bad_assignee).is_err()); + } + + #[test] + fn card_response_happy_path() { + let cid = uuid(); + let card = event_id(); + let ev = sign(build_card_response(cid, card, "a1", true).unwrap()); + assert_eq!(ev.kind.as_u16(), 40009); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert!(has_tag(&ev, "e", &card.to_hex())); + assert!(has_tag(&ev, "item", "a1")); + assert_eq!(ev.content, r#"{"done":true}"#); + + let uncheck = sign(build_card_response(cid, card, "a1", false).unwrap()); + assert_eq!(uncheck.content, r#"{"done":false}"#); + } + + #[test] + fn card_response_rejects_empty_item_id() { + assert!(build_card_response(uuid(), event_id(), "", true).is_err()); + } + #[test] fn message_happy_path() { let cid = uuid(); diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 4ee0cd4c88..780a6edd6b 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -56,6 +56,17 @@ pub struct DiffMeta { pub alt_text: Option, } +/// One item in a `buzz:todo-card` interactive card message +/// (see `docs/nips/NIP-TC.md`). +pub struct TodoCardItem { + /// Card-unique item id, referenced by kind:40009 response `item` tags. + pub id: String, + /// Human-readable item text. + pub text: String, + /// Optional assignee pubkey (64-char hex). Absent = anyone may complete. + pub assignee: Option, +} + /// Vote direction for `build_vote`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VoteDirection { diff --git a/docs/nips/NIP-TC.md b/docs/nips/NIP-TC.md new file mode 100644 index 0000000000..c8b95094b6 --- /dev/null +++ b/docs/nips/NIP-TC.md @@ -0,0 +1,147 @@ +NIP-TC +====== + +To-Do Cards +----------- + +`draft` `optional` `client` `relay` + +**Depends on**: NIP-01 (basic event format), NIP-29-style channel scoping (`h` tags) + +## Abstract + +This NIP defines interactive to-do cards inside ordinary Buzz channel messages. A card is a fenced ` ```buzz:todo-card ` JSON payload embedded in a normal stream message (`kind:9` / `kind:40002`); clients that understand the fence render a native checklist card, while every other client falls back to the message's prose. Check-offs are separate user-signed response events (`kind:40009`) that reference the card and item; card state is a pure client-side fold over those responses, so the relay stores nothing card-specific and needs no new read model. + +The protocol has one new event kind: + +- a user-signed card response (`kind:40009` interactive-card response). + +There is no card event kind. The card itself rides inside an existing message kind as a sentinel payload, mirroring the config-nudge sentinel pattern. + +## Motivation + +Teams coordinating in a Buzz channel routinely post checklists as prose ("- [ ] flip the flag"). Prose cannot be checked off, attributed, or kept in sync across viewers. A dedicated card event kind would require new relay storage, new feed/unread plumbing, and a migration for every render surface. Embedding the card in a normal message keeps delivery, threading, editing, deletion, unreads, and permissions exactly as they are — the card is just message content — while the small, append-only response events carry the interactive state. + +## Non-Goals + +This NIP does not define relay-side aggregation. The relay never computes card state; clients fold responses themselves. + +This NIP does not add read gates. `kind:40009` responses are ordinary channel-scoped events readable by any channel member, and they are deliberately absent from feed, unread, and mention queries. + +This NIP does not define composer UI. v1 cards are authored by agents and CLI tooling via the SDK builder; humans interact by checking items, not by composing cards. + +This NIP does not cover multi-list cards, due dates, ordering edits, or item mutation. A card's items are immutable after publish; only completion state changes. + +## Terminology + +This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as defined in RFC 2119. + +- **card message**: A `kind:9` or `kind:40002` stream message whose content contains a `buzz:todo-card` fenced payload. +- **card event id**: The Nostr event id of the card message. Responses reference it via an `e` tag. +- **item**: One checklist entry inside a card, identified by a card-unique string id. +- **assignee**: The optional pubkey named on an item. Absence means anyone in the channel may complete the item. +- **response**: A user-signed `kind:40009` event asserting `done` or not-done for one (card, item) pair. + +## Kinds + +| Kind | Name | Signer | Storage | Purpose | +|------|------|--------|---------|---------| +| `40009` | Interactive-Card Response | user | regular | One check/un-check of one card item | + +`kind:40009` is a regular event. It is channel-scoped: the relay requires an `h` tag and enforces channel membership and `MessagesWrite` scope exactly as for stream messages. It MUST NOT appear in timeline, unread, or mention kind sets — responses are not rows; clients fetch them per card by `#e` reference. + +## Payload Format + +### The `buzz:todo-card` sentinel + +A card message's content is ordinary prose followed by a fenced JSON payload: + +```` +Launch checklist for Thursday: + +```buzz:todo-card +{"v":1,"title":"Launch","items":[{"id":"a1","text":"Flip the flag","assignee":""},{"id":"b2","text":"Verify dashboards"}]} +``` +```` + +The v1 payload schema: + +```jsonc +{ + "v": 1, // REQUIRED, literal 1 + "title": "Launch", // OPTIONAL string + "items": [ // REQUIRED, 1..=20 entries + { + "id": "a1", // REQUIRED, non-empty, unique within the card + "text": "Flip the flag",// REQUIRED string + "assignee": "" // OPTIONAL 64-char lowercase hex pubkey + } + ] +} +``` + +Constraints: + +- `items` MUST contain between 1 and 20 entries. Clients MUST treat a payload violating any constraint as malformed. +- item `id`s MUST be non-empty and unique within the card. They are referenced verbatim by response `item` tags. +- A malformed, unterminated, over-cap, or wrong-version payload MUST cause the client to fall back to rendering the raw prose (including the fence) — never a partial card. +- The card message SHOULD carry one `p` tag per distinct assignee so existing mention delivery notifies them. + +Prose above the fence is the fallback body. A rendering client suppresses the fence and shows the native card; a non-rendering client shows the prose and the fence as plain text. + +### `kind:40009` Interactive-Card Response + +```jsonc +{ + "kind": 40009, + "pubkey": "", + "content": "{\"done\":true}", + "tags": [ + ["h", ""], + ["e", ""], + ["item", ""] + ] +} +``` + +Required tags: exactly one `h` (the card's channel), one `e` (the card message's event id), and one `item` (the item id from the card payload). The content is a JSON object with a single boolean field `done`. `{"done":false}` is an explicit un-check, not a deletion. + +Anyone with `MessagesWrite` in the channel MAY respond to any item, including items assigned to someone else — completion is attributed, not restricted. + +## State Fold + +Card state is a deterministic pure function of the card payload plus the set of `kind:40009` events whose `e` tag equals the card event id. For each item: + +1. Discard responses whose `item` tag names an unknown item id or whose content is not valid `{"done":bool}` JSON. +2. Order responses by `created_at` ascending, tie-broken by event id; keep only the **latest response per responder pubkey**. +3. If the item has an assignee and the assignee has responded, the assignee's latest response decides the item's state (an assignee's `{"done":false}` overrides anyone else's completion). +4. Otherwise the item is done iff any responder's latest response is `{"done":true}`; the most recent such responder is the attributed completer. + +Consequences: un-checking only retracts your own completion (your latest response flips to `done:false`; someone else's `done:true` still stands, except an assignee's un-check which is authoritative for their item), and every completion is attributable to the signing pubkey. + +## Relay Processing + +The relay treats `kind:40009` as one more channel-scoped content kind: `MessagesWrite` scope required, `h` tag required, channel membership enforced by the generic ingest pipeline. No new storage, index, or query path is introduced. Because feed, unread, and mention queries use explicit kind inclusion lists, responses are invisible to them by construction and MUST remain so — a check-off never creates an unread badge or a notification to the card author. + +## Client Behavior + +A rendering client SHOULD: + +1. Detect the sentinel when rendering a message body; on a valid payload, render the card and suppress the fence. +2. Subscribe live to `kinds:[40009]`, `#h:[]`, `#e:[]` while the card is on screen, folding events per §State Fold (deduplicating its own publish acknowledgements against subscription echoes). +3. Publish a `kind:40009` on toggle, disabling the control while the publish is in flight. +4. Disable un-check on items completed by someone else (except for the viewer's own completions), and attribute completions with the completer's profile. + +Mobile and other non-rendering clients need no changes: they show the prose fallback. + +## Security Considerations + +Responses are user-signed, so every check-off is attributable and unforgeable. The relay's existing channel-membership and scope enforcement is the only write gate; there is no way to respond to a card in a channel you cannot post in. Clients MUST validate the payload before rendering interactive controls so a crafted fence cannot render a partial or misleading card, and MUST ignore responses referencing unknown item ids. + +Because anyone in the channel may complete any item, a hostile channel member can mark items done; the fold's attribution (and an assignee's authoritative un-check) is the mitigation, matching the trust model of posting messages in the channel itself. + +## Relation to Other NIPs + +- **Config-nudge sentinel**: Same fenced-JSON-in-message carrier; NIP-TC generalizes it to interactive state with a response kind. +- **Huddle events (`kind:48100`–`48103`)**: Same client-side fold pattern (state = reduce over channel-scoped events); NIP-TC applies it per (card, item, pubkey). +- **NIP-29-style channel scoping**: `kind:40009` inherits the standard `h`-tag membership enforcement; no NIP-TC-specific gates exist.