mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
Define collaborative project change protocol
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
@@ -630,6 +630,18 @@ pub const KIND_GIT_STATUS_DRAFT: u32 = 1633;
|
||||
/// authority over any member: push policy reads the repository's own
|
||||
/// announcement, never a project. See `docs/nips/NIP-MP.md`.
|
||||
pub const KIND_PROJECT: u32 = 30621;
|
||||
/// Project collaboration: actor-signed transactional change command.
|
||||
///
|
||||
/// Version 1 changes only add or remove related channels. The stable target is
|
||||
/// a [`KIND_PROJECT`] coordinate; the relational Project state is authoritative.
|
||||
/// See `docs/nips/NIP-PC.md`.
|
||||
pub const KIND_PROJECT_CHANGE: u32 = 47010;
|
||||
/// Project collaboration: relay-signed addressable effective-state projection.
|
||||
///
|
||||
/// The owner-signed [`KIND_PROJECT`] remains the Project identity and recovery
|
||||
/// source. This projection exposes the relay's current relational state without
|
||||
/// forging a replacement under the owner's key. See `docs/nips/NIP-PC.md`.
|
||||
pub const KIND_PROJECT_STATE: u32 = 30623;
|
||||
|
||||
/// All registered kind constants — used for duplicate detection and iteration.
|
||||
pub const ALL_KINDS: &[u32] = &[
|
||||
@@ -763,6 +775,8 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_GIT_STATUS_CLOSED,
|
||||
KIND_GIT_STATUS_DRAFT,
|
||||
KIND_PROJECT,
|
||||
KIND_PROJECT_CHANGE,
|
||||
KIND_PROJECT_STATE,
|
||||
];
|
||||
|
||||
/// Returns `true` if `kind` is in the ephemeral range (20000–29999).
|
||||
@@ -822,6 +836,7 @@ pub const fn is_command_kind(kind: u32) -> bool {
|
||||
| KIND_WORKFLOW_TRIGGER
|
||||
| KIND_APPROVAL_GRANT
|
||||
| KIND_APPROVAL_DENY
|
||||
| KIND_PROJECT_CHANGE
|
||||
)
|
||||
}
|
||||
|
||||
@@ -836,6 +851,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool {
|
||||
| KIND_DM_VISIBILITY
|
||||
| KIND_THREAD_SUMMARY
|
||||
| KIND_WINDOW_BOUNDS
|
||||
| KIND_PROJECT_STATE
|
||||
)
|
||||
}
|
||||
|
||||
@@ -862,8 +878,11 @@ const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT_STATE)); // 30623 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999
|
||||
const _: () = assert!(is_command_kind(KIND_PROJECT_CHANGE));
|
||||
const _: () = assert!(is_relay_only_kind(KIND_PROJECT_STATE));
|
||||
|
||||
// Compile-time: NIP-34 parameterized replaceable kinds are in the correct range.
|
||||
const _: () = assert!(
|
||||
@@ -913,6 +932,17 @@ mod tests {
|
||||
assert!(!is_relay_only_kind(KIND_NIP43_LEAVE_REQUEST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_collaboration_kinds_have_distinct_routing() {
|
||||
assert!(is_command_kind(KIND_PROJECT_CHANGE));
|
||||
assert!(!is_relay_only_kind(KIND_PROJECT_CHANGE));
|
||||
assert!(is_parameterized_replaceable(KIND_PROJECT_STATE));
|
||||
assert!(is_relay_only_kind(KIND_PROJECT_STATE));
|
||||
assert!(!is_command_kind(KIND_PROJECT_STATE));
|
||||
assert!(!is_command_kind(KIND_PROJECT));
|
||||
assert!(!is_relay_only_kind(KIND_PROJECT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameterized_replaceable_range() {
|
||||
assert!(!is_parameterized_replaceable(29999));
|
||||
|
||||
@@ -12,7 +12,7 @@ use buzz_core::{
|
||||
KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST,
|
||||
KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT,
|
||||
KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_PROJECT,
|
||||
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
|
||||
KIND_PROJECT_CHANGE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
|
||||
},
|
||||
observer::{
|
||||
content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
|
||||
@@ -1993,6 +1993,8 @@ pub const PROJECT_CHANNEL_MAX: usize = 256;
|
||||
pub const PROJECT_VISIBILITY_MAX: usize = 256;
|
||||
/// Maximum number of `a` member tags per project event (checked before dedup).
|
||||
pub const PROJECT_MEMBER_CAP: usize = 64;
|
||||
/// Maximum number of related channels in either side of one Project change.
|
||||
pub const PROJECT_RELATED_CHANNEL_CHANGE_CAP: usize = 64;
|
||||
|
||||
/// A validated NIP-MP member `a`-tag coordinate with an optional relay hint.
|
||||
///
|
||||
@@ -2005,6 +2007,23 @@ pub struct ProjectMemberCoord {
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ProjectChangeContent<'a> {
|
||||
v: u8,
|
||||
patch: ProjectChangePatch<'a>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ProjectChangePatch<'a> {
|
||||
related_channels: ProjectRelatedChannelPatch<'a>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ProjectRelatedChannelPatch<'a> {
|
||||
add: &'a [String],
|
||||
remove: &'a [String],
|
||||
}
|
||||
|
||||
impl PartialEq for ProjectMemberCoord {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.coord == other.coord
|
||||
@@ -2300,6 +2319,93 @@ pub fn build_project(
|
||||
build_project_with_tags("", tags)
|
||||
}
|
||||
|
||||
fn validate_project_coordinate(coordinate: &str) -> Result<(), SdkError> {
|
||||
let mut parts = coordinate.splitn(3, ':');
|
||||
let kind = parts.next().unwrap_or_default();
|
||||
let owner = parts.next().unwrap_or_default();
|
||||
let d = parts.next().unwrap_or_default();
|
||||
if kind != "30621"
|
||||
|| owner.len() != 64
|
||||
|| !owner
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_digit() || matches!(character, 'a'..='f'))
|
||||
|| d.is_empty()
|
||||
|| d.len() > PROJECT_D_MAX_LEN
|
||||
{
|
||||
return Err(SdkError::InvalidInput(
|
||||
"project coordinate must be canonical '30621:<lowercase-hex64>:<nonempty-d>'".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a version-1 Project related-channel change command.
|
||||
///
|
||||
/// The command uses `expected_revision` for compare-and-swap against the
|
||||
/// relay-authored state. UUID inputs serialize in canonical lowercase,
|
||||
/// hyphenated form. The relay remains responsible for state-dependent checks
|
||||
/// and actor authorization.
|
||||
pub fn build_project_related_channel_change(
|
||||
project_coordinate: &str,
|
||||
expected_revision: u64,
|
||||
add: &[Uuid],
|
||||
remove: &[Uuid],
|
||||
) -> Result<EventBuilder, SdkError> {
|
||||
validate_project_coordinate(project_coordinate)?;
|
||||
if expected_revision == 0 || expected_revision > i64::MAX as u64 {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"expected revision must be from 1 through {}",
|
||||
i64::MAX
|
||||
)));
|
||||
}
|
||||
if add.is_empty() && remove.is_empty() {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"Project change patch must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if add.len() > PROJECT_RELATED_CHANNEL_CHANGE_CAP
|
||||
|| remove.len() > PROJECT_RELATED_CHANNEL_CHANGE_CAP
|
||||
{
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"Project change may add or remove at most {PROJECT_RELATED_CHANNEL_CHANGE_CAP} related channels"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut add: Vec<String> = add.iter().map(Uuid::to_string).collect();
|
||||
let mut remove: Vec<String> = remove.iter().map(Uuid::to_string).collect();
|
||||
add.sort_unstable();
|
||||
remove.sort_unstable();
|
||||
let add_set: std::collections::HashSet<&str> = add.iter().map(String::as_str).collect();
|
||||
let remove_set: std::collections::HashSet<&str> = remove.iter().map(String::as_str).collect();
|
||||
if add_set.len() != add.len() || remove_set.len() != remove.len() {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"Project change contains a duplicate related channel".into(),
|
||||
));
|
||||
}
|
||||
if add_set.iter().any(|channel| remove_set.contains(channel)) {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"Project change cannot add and remove the same related channel".into(),
|
||||
));
|
||||
}
|
||||
let content = serde_json::to_string(&ProjectChangeContent {
|
||||
v: 1,
|
||||
patch: ProjectChangePatch {
|
||||
related_channels: ProjectRelatedChannelPatch {
|
||||
add: &add,
|
||||
remove: &remove,
|
||||
},
|
||||
},
|
||||
})
|
||||
.map_err(|error| SdkError::InvalidInput(format!("invalid Project change: {error}")))?;
|
||||
let expected_revision = expected_revision.to_string();
|
||||
Ok(
|
||||
EventBuilder::new(Kind::Custom(KIND_PROJECT_CHANGE as u16), content).tags([
|
||||
tag(&["a", project_coordinate])?,
|
||||
tag(&["expected-revision", &expected_revision])?,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
/// **Generic NIP-09 coordinate delete**: Build a kind:5 deletion event with
|
||||
/// a single `a`-tag addressing `<kind>:<pubkey>:<d>`.
|
||||
///
|
||||
@@ -4782,6 +4888,75 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Project collaboration command ──────────────────────────────────────
|
||||
|
||||
const PROJECT_COORDINATE: &str =
|
||||
"30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform";
|
||||
const PROJECT_RELATED: Uuid = Uuid::from_u128(0x22222222_2222_4222_8222_222222222222);
|
||||
|
||||
#[test]
|
||||
fn project_related_channel_change_has_canonical_v1_shape() {
|
||||
let event = sign(
|
||||
build_project_related_channel_change(PROJECT_COORDINATE, 7, &[PROJECT_RELATED], &[])
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(event.kind.as_u16() as u32, KIND_PROJECT_CHANGE);
|
||||
assert_eq!(
|
||||
event.content,
|
||||
r#"{"v":1,"patch":{"related_channels":{"add":["22222222-2222-4222-8222-222222222222"],"remove":[]}}}"#
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.map(|event_tag| event_tag.as_slice().to_vec())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
vec!["a".to_string(), PROJECT_COORDINATE.to_string()],
|
||||
vec!["expected-revision".to_string(), "7".to_string()],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_related_channel_change_rejects_invalid_patches() {
|
||||
assert!(build_project_related_channel_change(PROJECT_COORDINATE, 1, &[], &[]).is_err());
|
||||
assert!(build_project_related_channel_change(
|
||||
PROJECT_COORDINATE,
|
||||
0,
|
||||
&[PROJECT_RELATED],
|
||||
&[]
|
||||
)
|
||||
.is_err());
|
||||
assert!(build_project_related_channel_change(
|
||||
PROJECT_COORDINATE,
|
||||
1,
|
||||
&[PROJECT_RELATED, PROJECT_RELATED],
|
||||
&[]
|
||||
)
|
||||
.is_err());
|
||||
assert!(build_project_related_channel_change(
|
||||
PROJECT_COORDINATE,
|
||||
1,
|
||||
&[PROJECT_RELATED],
|
||||
&[PROJECT_RELATED]
|
||||
)
|
||||
.is_err());
|
||||
assert!(build_project_related_channel_change(
|
||||
&PROJECT_COORDINATE.to_uppercase(),
|
||||
1,
|
||||
&[PROJECT_RELATED],
|
||||
&[]
|
||||
)
|
||||
.is_err());
|
||||
let over_cap = (0..=PROJECT_RELATED_CHANNEL_CHANGE_CAP)
|
||||
.map(|index| Uuid::from_u128(index as u128 + 1))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
build_project_related_channel_change(PROJECT_COORDINATE, 1, &over_cap, &[]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
// ── NIP-MP conformance fixtures ──────────────────────────────────────────
|
||||
// `build_project_with_tags` directly. Accept cases must build; reject
|
||||
// cases must fail with an error message containing the expected rule name.
|
||||
|
||||
@@ -28,7 +28,7 @@ One custom kind, held by one signer, with all group state in one replaceable eve
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This NIP does not define shared or delegated project editing — a project is replaceable only by its own signer (see [Authority](#authority)).
|
||||
This NIP does not itself define shared or delegated Project editing: `kind:30621` is replaceable only by its signer. A relay MAY additionally implement [NIP-PC](NIP-PC.md), whose actor-signed commands and relay-signed projection leave that owner-only identity unchanged.
|
||||
This NIP does not define any authorization over member repositories. Membership is not a permission grant, and a project is never consulted by git push policy.
|
||||
This NIP does not define project-level branch protection, CI, or workflow configuration.
|
||||
This NIP does not define nested projects. A project's members are repositories, never other projects.
|
||||
@@ -140,9 +140,9 @@ Clients MUST preserve each member repository's own owner provenance in the UI. A
|
||||
|
||||
### Editing model
|
||||
|
||||
Editing is **owner-only**: publish a replacement `kind:30621` with the same `d` and a newer `created_at`. Adding, removing, or reordering members and changing metadata are all one operation — replacing the container. This falls out of the addressable-event model with no relay-side permission machinery; NIP-01 replacement already refuses to let one pubkey overwrite another's coordinate.
|
||||
Editing the portable `kind:30621` identity is **owner-only**: publish a replacement with the same `d` and a newer `created_at`. Adding, removing, or reordering members and changing metadata are all one operation — replacing the container. This falls out of the addressable-event model with no relay-side permission machinery; NIP-01 replacement already refuses to let one pubkey overwrite another's coordinate.
|
||||
|
||||
Delegated or maintainer editing is deliberately out of scope for this version. Adding it later needs no change to this event shape — only a new rule about who may replace a coordinate.
|
||||
Optional [NIP-PC](NIP-PC.md) collaboration does not widen replacement authority. It records authorized actor-signed changes separately and projects effective relay state while `kind:30621` remains the owner-controlled identity and recovery source.
|
||||
|
||||
### Zero-member projects
|
||||
|
||||
@@ -324,7 +324,7 @@ Its cases are **semantic, not signed envelopes**. A repository or project is nam
|
||||
## Relation to Other NIPs
|
||||
|
||||
- **NIP-34**: Supplies the member repositories. Members are `kind:30617` announcements referenced by coordinate; a NIP-34 client that does not know `kind:30621` still discovers and renders each repository normally.
|
||||
- **NIP-01**: Supplies the addressable-event class, the `a` tag grammar, addressing, replacement, and the owner-only editing model. Owner-only editing is not enforcement code in Buzz — it is what NIP-01 replacement already means.
|
||||
- **NIP-01**: Supplies the addressable-event class, the `a` tag grammar, addressing, replacement, and the owner-only `kind:30621` identity model. Owner-only replacement is not enforcement code in Buzz — it is what NIP-01 replacement already means. Optional NIP-PC collaboration never replaces this coordinate for another actor.
|
||||
- **NIP-09**: Supplies container deletion, which deletes the container only. Buzz extends it in two ways that are not project-specific: an agent's registered NIP-OA owner may also delete, and a tombstone applies only at or before its own `created_at` ([Deletion](#deletion)).
|
||||
- **NIP-29**: Supplies the channel a project's `buzz-channel` names. The reference is metadata; project state is never channel-scoped.
|
||||
- **NIP-51**: The closest existing precedent — a signed, addressable list referencing content the author need not own. Not reused because a project is a shared named forge container with its own channel binding and visibility, not a user's private-or-public bookmark set.
|
||||
|
||||
159
docs/nips/NIP-PC.md
Normal file
159
docs/nips/NIP-PC.md
Normal file
@@ -0,0 +1,159 @@
|
||||
NIP-PC
|
||||
======
|
||||
|
||||
Collaborative Project Changes
|
||||
-----------------------------
|
||||
|
||||
`draft` `optional` `relay`
|
||||
|
||||
**Depends on**: NIP-01 (basic event format and addressable events), NIP-09 (event deletion), NIP-29 (home-channel authority), and [NIP-MP](NIP-MP.md) (owner-signed Project identity)
|
||||
|
||||
## Abstract
|
||||
|
||||
This NIP separates a Project's stable identity from its collaboratively editable effective state. The owner-signed NIP-MP `kind:30621` remains the Project identity and recovery source. An authorized actor requests an atomic change with `kind:47010`, and the relay exposes the resulting relational state as a relay-signed addressable `kind:30623` projection.
|
||||
|
||||
Version 1 changes only the bounded `buzz-related-channel` collection. The command and projection are versioned so later revisions can add fields without turning `kind:30621` into a shared-signing event or forging an owner-authored replacement.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This NIP does not give a Project authority over a repository, channel, or other referenced entity.
|
||||
|
||||
This NIP does not make `kind:30621` relay-signed or collaboratively replaceable. Only its owner can replace it under NIP-01.
|
||||
|
||||
This NIP does not define Project creation, deletion, or owner recovery as `kind:47010` commands. Creation and recovery use owner-signed `kind:30621`; deletion uses the owner or NIP-OA-owner NIP-09 paths defined by NIP-MP. Home-channel owners and admins cannot delete a Project through the related-channel-only version 1 command.
|
||||
|
||||
This NIP does not implement relational persistence, relay execution, or client behavior. It fixes the wire contract those layers will implement.
|
||||
|
||||
## Kinds
|
||||
|
||||
| Kind | Name | Signer | Class | Purpose |
|
||||
|------|------|--------|-------|---------|
|
||||
| `47010` | Project Change | authorized actor | regular command | Atomically add or remove related channels at an expected revision |
|
||||
| `30623` | Project State | relay | addressable projection | Publish the relay's current effective Project state |
|
||||
|
||||
`kind:47010` is a global transactional command. It is stored as immutable attribution only when accepted; it is not effective state by itself.
|
||||
|
||||
`kind:30623` is relay-only. A relay MUST reject client-submitted events of this kind, including an event whose signer happens to equal the configured relay pubkey but which arrived through ordinary client ingest.
|
||||
|
||||
Client submission of `kind:47010` requires `repos:write`. `kind:30623` is relay-authored and therefore has no client write scope. Neither kind is channel-scoped; a stray `h` tag does not select a Project or a community.
|
||||
|
||||
## Project Coordinate
|
||||
|
||||
Both events identify a Project by its canonical NIP-01 coordinate:
|
||||
|
||||
```text
|
||||
30621:<owner-pubkey-hex>:<project-d>
|
||||
```
|
||||
|
||||
The kind segment is the literal `30621`, the owner is exactly 64 lowercase hexadecimal characters, and the Project `d` value is non-empty and preserved verbatim. Parsing splits on the first two colons so a `d` value containing a colon remains addressable.
|
||||
|
||||
## Project Change Event
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 47010,
|
||||
"pubkey": "<actor-pubkey-hex>",
|
||||
"tags": [
|
||||
["a", "30621:<owner-pubkey-hex>:<project-d>"],
|
||||
["expected-revision", "7"]
|
||||
],
|
||||
"content": "{\"v\":1,\"patch\":{\"related_channels\":{\"add\":[\"11111111-1111-4111-8111-111111111111\"],\"remove\":[]}}}"
|
||||
}
|
||||
```
|
||||
|
||||
The event MUST contain exactly one two-element `a` tag and exactly one two-element `expected-revision` tag. The expected revision is a canonical base-10 integer in `1..=9223372036854775807`: digits only, no sign, and no leading zero. The command compares this value with the authoritative relational revision, never with a Project State event id.
|
||||
|
||||
The JSON content has this version 1 shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"patch": {
|
||||
"related_channels": {
|
||||
"add": ["<canonical-uuid>"],
|
||||
"remove": ["<canonical-uuid>"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every UUID MUST equal its lowercase hyphenated `Uuid::to_string()` form. `add` and `remove` are both required arrays, may each contain at most 64 entries, and are interpreted as sets. An event is invalid if either array contains a duplicate, the arrays overlap, both are empty, the resulting collection exceeds 64 channels, or the requested related channel is the current home channel.
|
||||
|
||||
Adding an already-related channel or removing an absent channel is invalid. A command that produces no semantic change MUST NOT advance the revision. Retrying the exact same accepted event id is instead an idempotent success, even after later changes have advanced the Project.
|
||||
|
||||
Version 1 decoders MUST reject unknown JSON fields. This prevents a newer client from receiving success when an older relay ignored its intent. A later version may add typed field patches while preserving the same coordinate and CAS envelope.
|
||||
|
||||
## Authorization
|
||||
|
||||
The Project owner is always authorized to issue a version 1 change.
|
||||
|
||||
A different actor is authorized only when the effective Project state has a resolvable home channel and the actor is an active `owner` or `admin` of that channel. An archived or deleted home channel grants no authority. Authorization reads the current relational Project state and current channel membership under the same serialization boundary as the CAS; an event projection or stale client snapshot is not an authorization source.
|
||||
|
||||
No Project relationship grants authority over a related channel. Adding a channel records grouping metadata only.
|
||||
|
||||
## Relational State and CAS
|
||||
|
||||
The authoritative row is keyed by `(community, Project owner, Project d)` and carries a monotonic signed-64-bit revision, deletion state, the current owner identity event id, the last accepted change or lifecycle event id, and the effective Project document.
|
||||
|
||||
The first accepted owner-signed `kind:30621` materializes revision `1`. Every accepted Project Change, owner recovery replacement, deletion, or recreation increments the revision by one. Revisions never reset, including after deletion and recreation, which prevents an ABA conflict from making an old command current again. Overflow rejects the mutation.
|
||||
|
||||
Applying a Project Change is one transaction: verify authorization, compare `expected-revision`, apply the entire patch to a copy, validate all invariants, insert the immutable command event, and CAS the authoritative row. Any failure leaves both the event log and Project state unchanged.
|
||||
|
||||
## Owner Identity and Recovery
|
||||
|
||||
An accepted newer owner-signed `kind:30621` is a full recovery snapshot. It replaces the effective NIP-MP fields and extension tags with those carried by that owner event, advances the relational revision, and leaves the stable Project coordinate unchanged. A duplicate or superseded `kind:30621` does not advance the revision.
|
||||
|
||||
Unknown NIP-MP extension tags are preserved byte-for-byte across collaborative version 1 changes. Transport-only `auth` tags are not Project metadata and are not carried into effective state. Owner recovery may intentionally replace or remove extension tags because the owner event is the recovery source.
|
||||
|
||||
An accepted owner-authorized NIP-09 deletion advances the row to a deleted tombstone. It does not delete member repositories or related channels. A later valid owner-signed `kind:30621` recreates the Project at the next revision rather than resetting to revision `1`.
|
||||
|
||||
## Project State Projection
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 30623,
|
||||
"pubkey": "<relay-identity-pubkey-hex>",
|
||||
"tags": [
|
||||
["d", "<sha256-project-coordinate-hex>"],
|
||||
["a", "30621:<owner-pubkey-hex>:<project-d>"],
|
||||
["rev", "8"],
|
||||
["e", "<current-kind-30621-event-id>", "", "identity"],
|
||||
["e", "<last-change-or-lifecycle-event-id>", "", "change"]
|
||||
],
|
||||
"content": "{\"v\":1,\"deleted\":false,\"project_tags\":[[\"d\",\"<project-d>\"],[\"buzz-related-channel\",\"11111111-1111-4111-8111-111111111111\"]]}"
|
||||
}
|
||||
```
|
||||
|
||||
The address `d` is the lowercase SHA-256 hex digest of the UTF-8 canonical Project coordinate. Hashing keeps the projection key fixed at 64 bytes even when the Project's own `d` approaches its 1024-byte bound. The `a` tag carries the unhashed coordinate. The `rev` tag uses the same canonical integer grammar as `expected-revision`.
|
||||
|
||||
The `identity` event id names the current owner-signed `kind:30621`. The `change` event id names the exact command, owner recovery, deletion, or recreation that produced this revision. Both are lowercase 64-character event ids. On initial materialization or owner recovery they may name the same `kind:30621` event.
|
||||
|
||||
For a live Project, `project_tags` is the complete effective NIP-MP-compatible tag set, including exactly one Project-slug `d` tag and the effective `buzz-related-channel` tags. Known set-valued fields are emitted in deterministic lexical order. Preserved unknown extension tags retain their byte values and relative order. Array order carries no Project membership semantics.
|
||||
|
||||
A deleted Project is projected as:
|
||||
|
||||
```json
|
||||
{"v":1,"deleted":true,"project_tags":[]}
|
||||
```
|
||||
|
||||
Clients MUST use `rev` as the CAS token. Projection repair may sign a newer `kind:30623` event with the same revision and content, so the projection event id is not a revision.
|
||||
|
||||
## Publication and Reconciliation
|
||||
|
||||
The relational row remains authoritative if projection publication or fan-out fails. The relay MUST retain a durable retry or reconciliation path and MUST NOT report a committed mutation as rolled back merely because derived publication failed. Reconciliation republishes the current row without advancing its revision.
|
||||
|
||||
Every newly signed projection for one address MUST have a `created_at` strictly greater than every previously accepted projection at that address, including a repair that republishes the same revision. Allocation MUST occur under the coordinate's serialization boundary as `max(current Unix time, previous projection created_at + 1)`; wall-clock time alone is insufficient. If no greater timestamp can be represented, publication fails without changing authoritative state.
|
||||
|
||||
## Schema Evolution
|
||||
|
||||
Command and projection content have independent explicit versions. A decoder that does not understand a command version MUST reject it. A client that does not understand a Project State version MUST ignore that projection rather than guess at its state.
|
||||
|
||||
Future command versions should add typed field patches. They must keep omitted fields unchanged, use collection deltas rather than whole-list replacement where independent edits are expected, preserve unknown extension tags, and retain the same monotonic CAS and provenance rules.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Clients MUST accept Project State only from the relay identity advertised through the relay's trusted identity mechanism. Structural parsing alone does not establish that trust.
|
||||
|
||||
The command signer remains attributable through the immutable `kind:47010` event. The relay-signed projection states what became effective; it does not rewrite history to make the Project owner appear to have signed a collaborator's change.
|
||||
|
||||
The Project coordinate selects state only inside the host-bound community. A relay MUST never derive the community from a client-supplied Project tag.
|
||||
Reference in New Issue
Block a user