Define conversation subject protocols and core domain types

Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
Bradley Axen
2026-09-15 11:56:20 +02:00
parent 4cd82f5132
commit a0c0508489
5 changed files with 623 additions and 0 deletions

View File

@@ -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.

View File

@@ -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<Uuid>,
/// 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(())
}

147
docs/nips/NIP-CS.md Normal file
View File

@@ -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": ["<K>"], "#h": ["<channel>"], "#d": ["<subject>"]}]
```
`<K>` 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.

121
docs/nips/NIP-PT.md Normal file
View File

@@ -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", "<project-uuid>"],
["h", "<channel-uuid>"],
["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", "<task-uuid>"],
["h", "<project-channel-uuid>"],
["location", "thread"],
["root", "<discussion-anchor-event-id>"],
["claim", "primary"],
["project", "<project-uuid>"]
],
"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.

133
docs/nips/NIP-RB.md Normal file
View File

@@ -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", "<repository-uuid>"],
["h", "<repository-channel-uuid>"],
["location", "channel"],
["claim", "exclusive"],
["a", "30617:<owner-public-key>: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", "<branch-uuid>"],
["h", "<discussion-channel-uuid>"],
["location", "thread"],
["root", "<discussion-anchor-event-id>"],
["claim", "attachment"],
["repository", "<repository-uuid>"],
["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.