diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 36dc772da3..dee7ab9f39 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -38,6 +38,8 @@ pub mod presence; pub mod private_managed_agent; /// Canonical relay runtime identities. pub mod relay; +/// Conversation subject types and structural validation. +pub mod subject; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; /// Schnorr signature and event ID verification. diff --git a/crates/buzz-core/src/subject.rs b/crates/buzz-core/src/subject.rs new file mode 100644 index 0000000000..02d32fd7ed --- /dev/null +++ b/crates/buzz-core/src/subject.rs @@ -0,0 +1,220 @@ +//! Conversation subjects: domain state independent of its conversation home. +//! +//! Domain values decoded from subject declarations. Transport tags, +//! authorization, and transactional relationship validation are separate from +//! structural validation. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A typed thing discussed in a channel or thread. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum Subject { + /// An outcome grouping repository-independent tasks. + Project { + /// Human-readable project name. + title: String, + }, + /// A unit of work belonging to a project, not a repository. + Task { + /// Human-readable task title. + title: String, + /// Stable Buzz project identity. + project_id: Uuid, + /// Optional parent task in the same project. + parent_task_id: Option, + /// Task workflow state. + status: TaskStatus, + }, + /// A Buzz repository whose Git representation is NIP-34. + Repository { + /// Human-readable repository name. + title: String, + /// Address of the upstream repository announcement, not an event ID. + nip34_coordinate: String, + }, + /// A branch incarnation, distinct from a ref-state snapshot. + Branch { + /// Stable Buzz repository identity. + repository_id: Uuid, + /// Full Git branch ref, for example `refs/heads/fix-login`. + ref_name: String, + }, +} + +/// Initial task workflow vocabulary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskStatus { + /// Work has not started. + Open, + /// Work is underway. + InProgress, + /// Work is complete. + Done, + /// Work was abandoned. + Cancelled, +} + +/// A conversation location. Threads inherit their channel's access rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum Conversation { + /// Canonical home for a project or repository. + Channel { + /// Authorization channel. + channel_id: Uuid, + }, + /// Canonical discussion for a task or branch. + Thread { + /// Authorization channel, not necessarily the branch repository's channel. + channel_id: Uuid, + /// Stable conversation anchor, independent of metadata revisions. + root_event_id: nostr::EventId, + }, +} + +/// Internal structural input. Relationships are decoded from event tags, +/// rather than duplicated in a second JSON wire representation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectDeclaration { + /// Stable identity shared by all revisions. + pub id: Uuid, + /// Canonical conversation location. + pub conversation: Conversation, + /// Type-specific state. + pub subject: Subject, +} + +/// A malformed declaration. Existence and permission errors belong to the relay. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("invalid conversation subject: {0}")] +pub struct SubjectError(pub &'static str); + +impl SubjectDeclaration { + /// Validate shape only. This does not establish acceptance or authority. + pub fn validate(&self) -> Result<(), SubjectError> { + non_nil(self.id)?; + let channel_id = match self.conversation { + Conversation::Channel { channel_id } => channel_id, + Conversation::Thread { channel_id, .. } => channel_id, + }; + non_nil(channel_id)?; + match (&self.subject, &self.conversation) { + ( + Subject::Project { .. } | Subject::Repository { .. }, + Conversation::Channel { .. }, + ) + | (Subject::Task { .. } | Subject::Branch { .. }, Conversation::Thread { .. }) => {} + _ => { + return Err(SubjectError( + "subject type does not match conversation type", + )) + } + } + match &self.subject { + Subject::Project { title } => validate_title(title), + Subject::Task { + title, + project_id, + parent_task_id, + .. + } => { + validate_title(title)?; + non_nil(*project_id)?; + if *project_id == self.id { + return Err(SubjectError("task cannot be its own project")); + } + if let Some(parent) = parent_task_id { + non_nil(*parent)?; + if *parent == self.id { + return Err(SubjectError("task cannot parent itself")); + } + } + Ok(()) + } + Subject::Repository { + title, + nip34_coordinate, + } => { + validate_title(title)?; + validate_repository_coordinate(nip34_coordinate) + } + Subject::Branch { + repository_id, + ref_name, + } => { + non_nil(*repository_id)?; + if *repository_id == self.id { + return Err(SubjectError("branch cannot be its own repository")); + } + validate_branch_ref(ref_name) + } + } + } +} + +fn non_nil(id: Uuid) -> Result<(), SubjectError> { + if id.is_nil() { + Err(SubjectError("nil identity")) + } else { + Ok(()) + } +} + +fn validate_title(title: &str) -> Result<(), SubjectError> { + if title.trim().is_empty() || title.len() > 512 { + return Err(SubjectError( + "title must contain text and be at most 512 bytes", + )); + } + Ok(()) +} + +/// Validate a NIP-34 repository address without resolving it. +/// The identifier may contain colons; only the first two delimiters are structural. +pub fn validate_repository_coordinate(value: &str) -> Result<(), SubjectError> { + let mut parts = value.splitn(3, ':'); + if parts.next() != Some("30617") { + return Err(SubjectError("expected a kind-30617 repository coordinate")); + } + let key = parts + .next() + .ok_or(SubjectError("missing repository author"))?; + if key.len() != 64 + || !key + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + || nostr::PublicKey::from_hex(key).is_err() + { + return Err(SubjectError( + "repository author must be a lowercase hex public key", + )); + } + if parts.next().is_none() { + return Err(SubjectError("missing repository identifier")); + } + Ok(()) +} + +/// Validate a fully-qualified branch ref using Git's ref-name restrictions. +pub fn validate_branch_ref(value: &str) -> Result<(), SubjectError> { + let Some(name) = value.strip_prefix("refs/heads/") else { + return Err(SubjectError("branch ref must start with refs/heads/")); + }; + if name.is_empty() + || value.ends_with('.') + || value.contains("..") + || value.contains("@{") + || value + .bytes() + .any(|c| c <= b' ' || c == 127 || b"~^:?*[\\".contains(&c)) + || value + .split('/') + .any(|part| part.is_empty() || part.starts_with('.') || part.ends_with(".lock")) + { + return Err(SubjectError("invalid Git branch ref")); + } + Ok(()) +} diff --git a/docs/nips/NIP-CS.md b/docs/nips/NIP-CS.md new file mode 100644 index 0000000000..d3e877828f --- /dev/null +++ b/docs/nips/NIP-CS.md @@ -0,0 +1,147 @@ +NIP-CS +====== + +Conversation Subjects +--------------------- + +`draft` `optional` `relay` + +**Depends on:** NIP-01 (signed events and filters), NIP-29 (channels). + +## Abstract + +A **subject** is an identifiable object with a canonical conversation location. +This NIP defines tags for that relationship, how several subjects may occupy the +same location, and how declarations are validated. It allocates no event kind. +Each participating kind specifies its content, permitted relationship mode, and +additional authorization requirements. + +MUST, MUST NOT, SHOULD, and MAY are interpreted as in RFC 2119. + +## Declaration tags + +A declaration is a complete state revision, not a patch. All defined tags have +exactly two string elements. Required tags occur exactly once; optional tags at +most once. Duplicate tags are invalid even when their values agree. + +| Tag | Cardinality | Value | +|-----|-------------|-------| +| `cs` | 1 | Protocol version: `1` | +| `d` | 1 | Subject UUID, lowercase canonical hyphenated form, non-nil | +| `h` | 1 | Authorization channel UUID, same encoding as `d` | +| `location` | 1 | `channel` or `thread` | +| `root` | 0 or 1 | Thread anchor event ID: 64 lowercase hex characters | +| `claim` | 1 | `exclusive`, `primary`, or `attachment` | +| `prev` | 0 or 1 | Previous accepted revision's event ID | + +`root` MUST be absent for a channel location and present for a thread location. +The location key is `(community, h)` for a channel, or `(community, h, root)` for +a thread. A channel claim does not reserve every thread within that channel. + +`d` identifies the subject within its community, independent of author and kind. +It is not a NIP-01 addressable coordinate. Participating kinds use regular stored +events; the relay retains revisions and maintains their current heads separately. + +Unknown tags MAY carry non-authoritative annotations and MUST NOT change these +semantics. Unsupported `cs` versions MUST be rejected. Type specifications may +reserve additional tags and restrict their cardinality. Relationships MUST NOT +also be encoded as independently authoritative copies in `content`. + +## Claim modes + +| Existing claim | New exclusive | New primary | New attachment | +|----------------|---------------|-------------|----------------| +| None | Allow | Allow | Allow | +| Exclusive | Reject | Reject | Reject | +| Primary | Reject | Reject | Allow | +| Attachment(s) only | Reject | Allow | Allow | + +An **exclusive** subject is the only subject at its location. A **primary** +subject is the only primary subject, but permits attachments. An **attachment** +shares a location without reserving its primary position. Attachment does not +require a primary subject to exist. + +The concrete kind fixes its mode; a writer MUST NOT select a weaker mode to +bypass a constraint. Revisions of the same subject do not compete with its own +claim. Claims are checked across all participating kinds, not per kind. + +## Identity and revisions + +Creation omits `prev`. It reserves an unused `d`; its signer becomes the subject +controller. An update includes `prev` naming the current accepted head, uses the +same kind and `d`, and is signed by that controller. Channel administration alone +does not confer control of another subject. + +The relay MUST compare the head, validate relationships, store the event, and +advance the head in one transaction. Competing updates to the same head cannot +both succeed. A repeated event ID is an idempotent duplicate, not a new revision. +`created_at` does not determine the winning head. Rejected events are not +broadcast or included in queries. + +The channel, location, root, claim mode, and controller are immutable. Type +specifications identify additional immutable fields. Retirement is a +kind-specific state transition, not a transfer of the conversation. Deletion +requests MUST NOT release claims or remove a subject's anchor while references +remain; clients use retirement instead of NIP-09 deletion for subject state. + +## Thread anchors + +`root` names an existing, live, channel-scoped conversation anchor in `h`. +It MUST resolve within the same community. An anchor's event ID remains stable +when any subject metadata changes. Subject declarations are not conversation +replies: `e` root/reply markers MUST NOT be used to express the home relationship. +They MUST NOT increment reply counters or become ordinary timeline messages. + +Replies use the channel's existing thread protocol and reference the anchor, +not a subject's latest revision. For nested conversations, the home anchor can +be a descendant; its outer root and immediate-parent ancestry remain intact. +A subject declaration does not rewrite that ancestry or introduce membership. + +## Authorization + +The relay MUST check access to `h` on creation and every update. Exclusive and +primary claims additionally require channel administration; attachments require +channel write permission. Concrete kinds may require additional source-object +authority. Being able to read an object is not permission to disclose it to a +different channel. + +Every subject revision is channel-scoped by `h`. Queries, direct-ID reads, +counts, search, and live delivery MUST apply that channel's visibility rules. +The existence of an unreadable subject MUST NOT be disclosed through a failed +relationship lookup. A link grants neither source-object capabilities nor +conversation access. Thread access always derives from its one containing +channel, never the union or intersection of linked objects' audiences. + +## Retrieval + +Writes use Nostr `EVENT`; the generic HTTP equivalent is `POST /events`. +History and live updates use `REQ`; `POST /query` provides the corresponding +bounded historical query. No type-specific CRUD endpoint is required. + +For a participating kind `K`: + +```jsonc +// Subject revisions, with live updates after EOSE. +["REQ", "subject", {"kinds": [""], "#h": [""], "#d": [""]}] +``` + +`` denotes a numeric kind, not a literal string on the wire. Filters on `d` +MUST be applied before LIMIT even though these kinds are not addressable events. +History is revision history, not a current-state list. Clients fold accepted +revisions using `prev`, deduplicating by event ID; an incomplete chain requires +fetching missing revisions rather than selecting the largest timestamp. + +Location queries use `#h` and `#root` semantics through the relay's generic query +extension; multi-character tag filtering is a Buzz extension, not guaranteed by +NIP-01. All relationship predicates and authorization MUST run before pagination. +A relay MUST reject unsupported relationship filters rather than ignore them. +EOSE means the historical response ended; it is not a durable replay cursor. + +## Invalid declarations + +- Two `h` tags, even if identical. +- A thread location without `root`, or a channel location with `root`. +- An exclusive claim at a location already occupied by an attachment. +- A second primary subject at a location with an existing primary. +- An update with a stale `prev`, different signer, or changed home. +- A thread anchor in a different channel or community. diff --git a/docs/nips/NIP-PT.md b/docs/nips/NIP-PT.md new file mode 100644 index 0000000000..14030bf828 --- /dev/null +++ b/docs/nips/NIP-PT.md @@ -0,0 +1,121 @@ +NIP-PT +====== + +Projects and Tasks +------------------ + +`draft` `optional` `relay` + +**Depends on:** NIP-CS (conversation subjects). + +## Kinds + +| Kind | Name | Location | Claim | +|------|------|----------|-------| +| `45010` | Project | channel | exclusive | +| `45012` | Task | thread | primary | + +These are regular stored events, not addressable replacements. Identity, +controller authority, revision chaining, and common tags follow NIP-CS. +A project groups tasks, not repositories. Neither type requires a Git reference. + +## Project event + +```jsonc +{ + "kind": 45010, + "tags": [ + ["cs", "1"], + ["d", ""], + ["h", ""], + ["location", "channel"], + ["claim", "exclusive"] + ], + "content": "{\"title\":\"Checkout reliability\",\"description\":\"Reduce failed checkouts\",\"archived\":false}" +} +``` + +Standard signed-event fields are omitted in examples. An update also supplies +`prev` as defined in NIP-CS. + +| Content field | JSON type | Constraints | +|---------------|-----------|-------------| +| `title` | string | Required; non-whitespace; at most 512 UTF-8 bytes | +| `description` | string | Required; Markdown; at most 65536 UTF-8 bytes | +| `archived` | boolean | Required | + +Content MUST be a JSON object without duplicate or unknown fields. Archiving +prevents creation of additional tasks but does not archive the channel, hide +existing tasks, revoke access, or release the channel claim. Unarchiving is an +ordinary authorized revision. + +## Task event + +```jsonc +{ + "kind": 45012, + "tags": [ + ["cs", "1"], + ["d", ""], + ["h", ""], + ["location", "thread"], + ["root", ""], + ["claim", "primary"], + ["project", ""] + ], + "content": "{\"title\":\"Handle payment timeout\",\"description\":\"Preserve the pending payment\",\"status\":\"open\"}" +} +``` + +| Additional tag | Cardinality | Meaning | +|----------------|-------------|---------| +| `project` | 1 | Project subject UUID | +| `parent` | 0 or 1 | Parent task subject UUID | + +Both tags have exactly two elements and use NIP-CS UUID encoding. They are +immutable after creation. A subtask remains a task, with its own primary thread +claim. Parentage alone does not require nested thread ancestry or create a new +authorization boundary. + +| Content field | JSON type | Constraints | +|---------------|-----------|-------------| +| `title` | string | Required; non-whitespace; at most 512 UTF-8 bytes | +| `description` | string | Required; Markdown; at most 65536 UTF-8 bytes | +| `status` | string | Required; `open`, `in_progress`, `done`, or `cancelled` | + +Content MUST reject duplicate and unknown fields. Any listed status may +transition to any other listed status under ordinary update authority. +Completion or cancellation does not delete the discussion or retire attached +subjects. There is no implied transition from Git merge state to task status. + +## Relationship validation + +The relay MUST resolve `project` to an accepted kind-45010 subject in the same +community. Its home channel MUST equal the task's `h`. At creation, the project +MUST NOT be archived. `parent`, when present, MUST resolve to a different +kind-45012 subject in the same project. Cyclic parentage MUST be rejected. + +A task's primary claim allows attachment subjects, but not another primary or +exclusive subject. Project and task creation require the NIP-CS claim authority; +updates require the controller and continued channel access. A task's controller +need not be the project's controller. + +Example rejections include a missing project, a parent from another project, +a task rooted in another channel, a second task on an occupied primary thread, +and an update changing `project` or `parent`. + +## Queries + +Project listing filters kind 45010 by `h`. Task listing filters kind 45012 by +`project`; subtask listing additionally filters `parent`. These relationship +filters use the generic relay query extension and MUST run before LIMIT. +Results are signed revisions under NIP-CS history semantics, not a separate +project-maintained task list. A client materializes current task state by +subject ID and accepted revision chain. + +## Compatibility + +Kind 30621 retains its multi-repository grouping semantics. Kind 1621 retains +its NIP-34 issue semantics. Neither is a project or task under this NIP merely +because it carries a channel reference. Conversion creates an explicit new +subject and preserves old event identities and links. diff --git a/docs/nips/NIP-RB.md b/docs/nips/NIP-RB.md new file mode 100644 index 0000000000..88209d6639 --- /dev/null +++ b/docs/nips/NIP-RB.md @@ -0,0 +1,133 @@ +NIP-RB +====== + +Repositories and Branches +------------------------- + +`draft` `optional` `relay` + +**Depends on:** NIP-CS (conversation subjects), NIP-34 (Git announcements). + +## Kinds + +| Kind | Name | Location | Claim | +|------|------|----------|-------| +| `45011` | Repository | channel | exclusive | +| `45014` | Branch | thread | attachment | + +These are regular stored events. NIP-CS defines identity, revision chaining, +controller authority, and common tags. A repository subject maps a Buzz identity +to a NIP-34 repository; it does not create another Git repository. A branch +subject identifies a collaboration lifecycle, not a ref-state snapshot. + +## Repository event + +```jsonc +{ + "kind": 45011, + "tags": [ + ["cs", "1"], + ["d", ""], + ["h", ""], + ["location", "channel"], + ["claim", "exclusive"], + ["a", "30617::checkout"] + ], + "content": "{\"title\":\"Checkout\",\"archived\":false}" +} +``` + +Standard signed-event fields are omitted. Updates include `prev`. + +| Additional tag | Cardinality | Meaning | +|----------------|-------------|---------| +| `a` | 1 | NIP-34 repository coordinate; exactly two tag elements | + +The coordinate MUST start with `30617:` followed by a 64-character lowercase +hex public key, a colon, and the repository identifier. Split on the first two +colons only; the remaining identifier is literal. The relay MUST resolve the +coordinate to an existing repository announcement in the same community. +The coordinate is immutable and unique among repository subjects in that +community, including archived subjects. + +| Content field | JSON type | Constraints | +|---------------|-----------|-------------| +| `title` | string | Required; non-whitespace; at most 512 UTF-8 bytes | +| `archived` | boolean | Required | + +Duplicate and unknown fields MUST be rejected. Archiving prevents new branch +subjects but does not delete code, hide discussions, or release the home claim. +Clone URLs, commit tips, and ref snapshots remain NIP-34 state, not independently +writable fields on this subject. + +## Branch event + +```jsonc +{ + "kind": 45014, + "tags": [ + ["cs", "1"], + ["d", ""], + ["h", ""], + ["location", "thread"], + ["root", ""], + ["claim", "attachment"], + ["repository", ""], + ["ref", "refs/heads/fix-timeout"] + ], + "content": "{\"retired\":false}" +} +``` + +| Additional tag | Cardinality | Meaning | +|----------------|-------------|---------| +| `repository` | 1 | Buzz repository UUID using NIP-CS encoding | +| `ref` | 1 | Full Git branch ref | + +Tags have exactly two elements. Both values are immutable. The repository MUST +resolve to kind 45011 in the same community and MUST NOT be archived on branch +creation. The branch resolves its NIP-34 target through that repository; it MUST +NOT repeat an independently authoritative coordinate. + +`ref` MUST start with `refs/heads/` and satisfy Git ref-name rules: no empty or +dot-prefixed path component, `.lock` component suffix, trailing dot, `..`, `@{`, +ASCII control/space, DEL, or any of `~ ^ : ? * [ \\`. A branch subject may precede +the first push; ref existence is not required. At most one non-retired subject +may occupy `(community, repository, ref)`. + +Content contains exactly the required boolean `retired`. Retirement is terminal: +a retired branch cannot become active again. A new incarnation uses a new UUID +and may claim the released ref slot, but the retired subject retains its +conversation association. Ref deletion alone does not retire a subject; rename +is retirement followed by creation, not a silent identity rewrite. + +## Authorization and disclosure + +Repository creation requires control of the NIP-34 announcement and channel +administration. Its Git access binding MUST match the subject's channel; a +conflicting binding MUST be rejected rather than silently overwritten. Later +Git metadata writes MUST NOT contradict an adopted binding. + +Branch creation and updates require the NIP-CS controller rules, destination +channel write permission, and repository administration authority. This is +stronger than code read or ordinary push permission: placing a ref name in +another channel constitutes disclosure to that audience. + +A branch's discussion channel MAY differ from its repository channel. Repository +readers do not thereby gain access to the discussion, and discussion readers do +not gain code access. Rendering a branch MUST NOT implicitly fetch or expose Git +content without a separate repository access check. Git-originated notifications +and agent output are subject to the same disclosure boundary. + +## Queries and compatibility + +Query repositories by kind 45011 and `h`; query branches by kind 45014 and +`repository`, optionally `ref`. Query discussion attachments by `h` and `root`. +Relationship filtering MUST precede pagination and use NIP-CS authorization and +revision-history semantics. + +NIP-34 announcement 30617 and ref state 30618 retain their identities. A 30618 +event may describe many refs and MUST NOT serve as a branch subject identity. +Pull requests, patches, and their statuses remain separate Git protocol objects. +Adopting a repository does not retrospectively restrict copies of previously +published metadata or reinterpret legacy comments as private channel replies.