Merge remote-tracking branch 'origin/jtennant/project-identity-lifecycle' into jtennant/project-state-integration

* origin/jtennant/project-identity-lifecycle:
  Track Project identity lifecycle atomically

Signed-off-by: John Tennant <jtennant@squareup.com>

# Conflicts:
#	crates/buzz-db/src/lib.rs
#	crates/buzz-db/src/store/project_state.rs
This commit is contained in:
John Tennant
2026-09-01 12:50:48 -04:00
4 changed files with 705 additions and 8 deletions

View File

@@ -54,7 +54,8 @@ pub use community::{
pub use error::{DbError, Result};
pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT};
pub use project_state::{
ProjectChangeApplyResult, ProjectRelatedChannelChange, ProjectStateProjectionCandidate,
ProjectChangeApplyResult, ProjectLifecycleApplyResult, ProjectLifecycleStatus,
ProjectRelatedChannelChange, ProjectStateProjectionCandidate,
ProjectStateProjectionCommitResult, ProjectStateSnapshot,
};
pub use reaction::ReactionEventInsertOutcome;

View File

@@ -2,11 +2,14 @@
use std::collections::BTreeSet;
use buzz_core::kind::{event_kind_u32, KIND_PROJECT, KIND_PROJECT_CHANGE, KIND_PROJECT_STATE};
use buzz_core::kind::{
event_kind_u32, KIND_DELETION, KIND_PROJECT, KIND_PROJECT_CHANGE, KIND_PROJECT_STATE,
};
use buzz_core::project_state::{
project_state_template, ProjectStateProjectionInput, ProjectStateTemplate,
};
use buzz_core::CommunityId;
use buzz_core::{CommunityId, StoredEvent};
use chrono::{DateTime, Utc};
use nostr::{Event, EventId};
use sqlx::Row;
use uuid::Uuid;
@@ -119,6 +122,40 @@ pub enum ProjectStateProjectionCommitResult {
Stale,
}
/// Result category for an owner identity or deletion lifecycle event.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProjectLifecycleStatus {
/// The event changed authoritative Project state.
Applied,
/// The event was newly stored but had no effect on the current Project head.
NoEffect,
/// The exact event was already stored.
Duplicate,
/// A newer owner identity already dominates the submitted identity.
Superseded,
}
/// Atomic persistence result for a Project lifecycle event.
#[derive(Clone, Debug)]
pub struct ProjectLifecycleApplyResult {
/// Stored representation used by relay dispatch when the event was inserted.
pub event: StoredEvent,
/// Whether and how authoritative Project state changed.
pub status: ProjectLifecycleStatus,
/// Canonical state after an applied lifecycle mutation.
pub snapshot: Option<ProjectStateSnapshot>,
}
impl ProjectLifecycleApplyResult {
/// Whether this call newly persisted the submitted event.
#[must_use]
pub fn was_inserted(&self) -> bool {
matches!(
self.status,
ProjectLifecycleStatus::Applied | ProjectLifecycleStatus::NoEffect
)
}
}
fn tag_parts(tag: &serde_json::Value) -> Option<Vec<&str>> {
tag.as_array()?
.iter()
@@ -193,7 +230,341 @@ fn validate_patch(change: ProjectRelatedChannelChange<'_>) -> Option<String> {
None
}
async fn replace_related_channels(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
community_id: CommunityId,
owner: &[u8],
d_tag: &str,
related: &BTreeSet<Uuid>,
) -> Result<()> {
sqlx::query(
"DELETE FROM project_related_channels WHERE community_id=$1 \
AND project_owner=$2 AND project_d_tag=$3",
)
.bind(community_id.as_uuid())
.bind(owner)
.bind(d_tag)
.execute(&mut **tx)
.await?;
for channel in related {
sqlx::query(
"INSERT INTO project_related_channels \
(community_id, project_owner, project_d_tag, channel_id) VALUES ($1,$2,$3,$4)",
)
.bind(community_id.as_uuid())
.bind(owner)
.bind(d_tag)
.bind(channel)
.execute(&mut **tx)
.await?;
}
Ok(())
}
impl Db {
/// Atomically accept an owner-signed Project identity and materialize it.
///
/// A newer identity is a full recovery snapshot. Existing relational
/// revisions advance rather than resetting, including recreation after a
/// deletion. Duplicate and superseded identities leave state unchanged.
pub async fn apply_project_identity_event(
&self,
community_id: CommunityId,
event: &Event,
) -> Result<ProjectLifecycleApplyResult> {
if event_kind_u32(event) != KIND_PROJECT {
return Err(DbError::InvalidData(
"Project identity persistence requires kind 30621".into(),
));
}
let d_tag = crate::event::extract_d_tag(event).unwrap_or_default();
if d_tag.is_empty() || d_tag.len() > crate::event::D_TAG_MAX_LEN {
return Err(DbError::InvalidData("invalid Project d tag".into()));
}
let tags = serde_json::to_value(&event.tags)?;
let (_, related) = parse_base_state(&tags).map_err(DbError::InvalidData)?;
let owner = event.pubkey.to_bytes();
let mut tx = self.begin_transaction().await?;
self.deletion_store()
.guard_transaction(&mut tx, community_id)
.await?;
let coordinate_lock = event_replacement_lock_key(
community_id,
KIND_PROJECT as i32,
owner.as_slice(),
Some(d_tag.as_bytes()),
);
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(coordinate_lock)
.execute(&mut *tx)
.await?;
let current_head = sqlx::query(
"SELECT revision, deleted, last_event_id FROM project_state_heads \
WHERE community_id=$1 AND project_owner=$2 AND project_d_tag=$3 FOR UPDATE",
)
.bind(community_id.as_uuid())
.bind(owner.as_slice())
.bind(&d_tag)
.fetch_optional(&mut *tx)
.await?;
if let Some(head) = current_head
.as_ref()
.filter(|head| head.get::<bool, _>("deleted"))
{
let tombstone_event_id: Vec<u8> = head.try_get("last_event_id")?;
let tombstone_created_at: DateTime<Utc> =
sqlx::query_scalar("SELECT created_at FROM events WHERE community_id=$1 AND id=$2")
.bind(community_id.as_uuid())
.bind(tombstone_event_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
DbError::InvalidData(
"Project tombstone event is missing from history".into(),
)
})?;
let identity_created_at =
DateTime::<Utc>::from_timestamp(event.created_at.as_secs() as i64, 0)
.ok_or_else(|| DbError::InvalidTimestamp(event.created_at.as_secs() as i64))?;
// A tombstone dominates every identity in its second. Recreating a
// Project requires an unambiguously later owner event.
if identity_created_at <= tombstone_created_at {
tx.rollback().await?;
return Ok(ProjectLifecycleApplyResult {
event: StoredEvent::new(event.clone(), None),
status: ProjectLifecycleStatus::Superseded,
snapshot: None,
});
}
}
let had_unmaterialized_identity: bool = if current_head.is_none() {
sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM events WHERE community_id=$1 AND kind=$2 \
AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL)",
)
.bind(community_id.as_uuid())
.bind(KIND_PROJECT as i32)
.bind(owner.as_slice())
.bind(&d_tag)
.fetch_one(&mut *tx)
.await?
} else {
false
};
let persisted = self
.replace_parameterized_event_in_transaction(
&mut tx,
community_id,
event,
&d_tag,
None,
ParameterizedReplacePrecondition::Unconditional,
)
.await?;
if persisted.status != ParameterizedReplaceStatus::Inserted {
tx.rollback().await?;
let status = match persisted.status {
ParameterizedReplaceStatus::Duplicate => ProjectLifecycleStatus::Duplicate,
_ => ProjectLifecycleStatus::Superseded,
};
return Ok(ProjectLifecycleApplyResult {
event: persisted.event,
status,
snapshot: None,
});
}
let revision = match current_head {
None if had_unmaterialized_identity => 2,
None => 1,
Some(head) => head
.try_get::<i64, _>("revision")?
.checked_add(1)
.ok_or_else(|| DbError::InvalidData("Project revision overflow".into()))?,
};
sqlx::query(
"INSERT INTO project_state_heads \
(community_id, project_owner, project_d_tag, revision, deleted, identity_event_id, last_event_id) \
VALUES ($1,$2,$3,$4,FALSE,$5,$5) \
ON CONFLICT (community_id, project_owner, project_d_tag) DO UPDATE SET \
revision=EXCLUDED.revision, deleted=FALSE, identity_event_id=EXCLUDED.identity_event_id, \
last_event_id=EXCLUDED.last_event_id, updated_at=transaction_timestamp()",
)
.bind(community_id.as_uuid())
.bind(owner.as_slice())
.bind(&d_tag)
.bind(revision)
.bind(event.id.as_bytes().as_slice())
.execute(&mut *tx)
.await?;
replace_related_channels(&mut tx, community_id, owner.as_slice(), &d_tag, &related).await?;
let snapshot = ProjectStateSnapshot {
revision,
identity_event_id: event.id.as_bytes().to_vec(),
change_event_id: event.id.as_bytes().to_vec(),
related_channels: related.into_iter().collect(),
};
tx.commit().await?;
Ok(ProjectLifecycleApplyResult {
event: persisted.event,
status: ProjectLifecycleStatus::Applied,
snapshot: Some(snapshot),
})
}
/// Atomically store an owner-authorized NIP-09 coordinate deletion and,
/// when it covers the live identity, advance Project state to a tombstone.
pub async fn apply_project_deletion_event(
&self,
community_id: CommunityId,
event: &Event,
project_owner: &[u8],
project_d_tag: &str,
expected_identity_event_id: Option<&[u8]>,
) -> Result<ProjectLifecycleApplyResult> {
if event_kind_u32(event) != KIND_DELETION
|| project_owner.len() != 32
|| project_d_tag.is_empty()
|| project_d_tag.len() > crate::event::D_TAG_MAX_LEN
|| expected_identity_event_id.is_some_and(|event_id| event_id.len() != 32)
{
return Err(DbError::InvalidData("invalid Project deletion".into()));
}
let deletion_created_at =
DateTime::<Utc>::from_timestamp(event.created_at.as_secs() as i64, 0)
.ok_or_else(|| DbError::InvalidTimestamp(event.created_at.as_secs() as i64))?;
let mut tx = self.begin_transaction().await?;
self.deletion_store()
.guard_transaction(&mut tx, community_id)
.await?;
let lock = event_replacement_lock_key(
community_id,
KIND_PROJECT as i32,
project_owner,
Some(project_d_tag.as_bytes()),
);
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(lock)
.execute(&mut *tx)
.await?;
let (stored_event, inserted) =
insert_event_in_transaction(&mut tx, community_id, event, None).await?;
if !inserted {
tx.rollback().await?;
return Ok(ProjectLifecycleApplyResult {
event: stored_event,
status: ProjectLifecycleStatus::Duplicate,
snapshot: None,
});
}
let live = sqlx::query(
"SELECT id, created_at FROM events WHERE community_id=$1 AND kind=$2 \
AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL \
ORDER BY created_at DESC, id ASC LIMIT 1 FOR UPDATE",
)
.bind(community_id.as_uuid())
.bind(KIND_PROJECT as i32)
.bind(project_owner)
.bind(project_d_tag)
.fetch_optional(&mut *tx)
.await?;
let Some(live) = live else {
tx.commit().await?;
return Ok(ProjectLifecycleApplyResult {
event: stored_event,
status: ProjectLifecycleStatus::NoEffect,
snapshot: None,
});
};
let identity_event_id: Vec<u8> = live.try_get("id")?;
let identity_created_at: DateTime<Utc> = live.try_get("created_at")?;
if identity_created_at > deletion_created_at
|| expected_identity_event_id
.is_some_and(|expected| expected != identity_event_id.as_slice())
{
tx.commit().await?;
return Ok(ProjectLifecycleApplyResult {
event: stored_event,
status: ProjectLifecycleStatus::NoEffect,
snapshot: None,
});
}
let head = sqlx::query(
"SELECT revision, deleted, identity_event_id FROM project_state_heads \
WHERE community_id=$1 AND project_owner=$2 AND project_d_tag=$3 FOR UPDATE",
)
.bind(community_id.as_uuid())
.bind(project_owner)
.bind(project_d_tag)
.fetch_optional(&mut *tx)
.await?;
let revision = if let Some(head) = head {
let materialized: Vec<u8> = head.try_get("identity_event_id")?;
if head.try_get::<bool, _>("deleted")? || materialized != identity_event_id {
return Err(DbError::InvalidData(
"Project lifecycle state does not match live identity".into(),
));
}
head.try_get::<i64, _>("revision")?
.checked_add(1)
.ok_or_else(|| DbError::InvalidData("Project revision overflow".into()))?
} else {
// Materialize the pre-existing identity at revision 1 before applying
// its first relational lifecycle event.
2
};
sqlx::query(
"UPDATE events SET deleted_at=transaction_timestamp() WHERE community_id=$1 \
AND id=$2 AND deleted_at IS NULL AND created_at <= $3",
)
.bind(community_id.as_uuid())
.bind(&identity_event_id)
.bind(deletion_created_at)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO project_state_heads \
(community_id, project_owner, project_d_tag, revision, deleted, identity_event_id, last_event_id) \
VALUES ($1,$2,$3,$4,TRUE,$5,$6) \
ON CONFLICT (community_id, project_owner, project_d_tag) DO UPDATE SET \
revision=EXCLUDED.revision, deleted=TRUE, last_event_id=EXCLUDED.last_event_id, \
updated_at=transaction_timestamp()",
)
.bind(community_id.as_uuid())
.bind(project_owner)
.bind(project_d_tag)
.bind(revision)
.bind(&identity_event_id)
.bind(event.id.as_bytes().as_slice())
.execute(&mut *tx)
.await?;
replace_related_channels(
&mut tx,
community_id,
project_owner,
project_d_tag,
&BTreeSet::new(),
)
.await?;
let snapshot = ProjectStateSnapshot {
revision,
identity_event_id,
change_event_id: event.id.as_bytes().to_vec(),
related_channels: Vec::new(),
};
tx.commit().await?;
Ok(ProjectLifecycleApplyResult {
event: stored_event,
status: ProjectLifecycleStatus::Applied,
snapshot: Some(snapshot),
})
}
/// Authorize and atomically apply one v1 Project related-channel command.
///
/// Lock order is community deletion fence, Project coordinate, then current
@@ -870,17 +1241,25 @@ mod tests {
.expect("insert member");
}
let seeded = Uuid::new_v4();
let identity_time = Utc::now().timestamp() as u64 - 100;
let base = EventBuilder::new(Kind::Custom(KIND_PROJECT as u16), "")
.tags([
Tag::parse(["d", "shared"]).expect("d tag"),
Tag::parse(["buzz-channel", &home.to_string()]).expect("home tag"),
Tag::parse(["buzz-related-channel", &seeded.to_string()]).expect("related tag"),
])
.custom_created_at(Timestamp::from(identity_time))
.sign_with_keys(&owner)
.expect("sign Project");
db.insert_event(community, &base, None)
let initial = db
.apply_project_identity_event(community, &base)
.await
.expect("insert Project");
.expect("insert Project identity");
assert_eq!(initial.status, ProjectLifecycleStatus::Applied);
assert_eq!(
initial.snapshot.as_ref().map(|state| state.revision),
Some(1)
);
let added = Uuid::new_v4();
let first = command(&admin, &owner, "shared", 1, &[added], &[]);
@@ -990,7 +1369,6 @@ mod tests {
current_revision: 3
}
);
let relay_a = Keys::generate();
let relay_a_bytes = relay_a.public_key().to_bytes();
let candidates = db
@@ -1099,6 +1477,145 @@ mod tests {
.expect("count rotated projection");
assert_eq!(live_projection_count, 1);
let recovered = Uuid::new_v4();
let recovery = EventBuilder::new(Kind::Custom(KIND_PROJECT as u16), "")
.tags([
Tag::parse(["d", "shared"]).expect("d tag"),
Tag::parse(["buzz-channel", &home.to_string()]).expect("home tag"),
Tag::parse(["buzz-related-channel", &recovered.to_string()]).expect("related tag"),
])
.custom_created_at(Timestamp::from(identity_time + 1))
.sign_with_keys(&owner)
.expect("sign recovery");
let recovery_result = db
.apply_project_identity_event(community, &recovery)
.await
.expect("apply recovery");
assert_eq!(recovery_result.status, ProjectLifecycleStatus::Applied);
let recovery_state = recovery_result.snapshot.expect("recovery state");
assert_eq!(recovery_state.revision, 5);
assert_eq!(recovery_state.related_channels, vec![recovered]);
assert_eq!(
db.apply_project_identity_event(community, &recovery)
.await
.expect("duplicate recovery")
.status,
ProjectLifecycleStatus::Duplicate
);
let stale_identity_deletion = EventBuilder::new(Kind::EventDeletion, "")
.tag(Tag::event(base.id))
.custom_created_at(Timestamp::from(identity_time + 2))
.sign_with_keys(&owner)
.expect("sign stale identity deletion");
assert_eq!(
db.apply_project_deletion_event(
community,
&stale_identity_deletion,
owner_bytes.as_slice(),
"shared",
Some(base.id.as_bytes()),
)
.await
.expect("ignore stale identity deletion")
.status,
ProjectLifecycleStatus::NoEffect
);
assert!(db
.get_event_by_id(community, recovery.id.as_bytes())
.await
.expect("load recovered identity")
.is_some());
let coordinate = format!("30621:{}:shared", owner.public_key().to_hex());
let deletion = EventBuilder::new(Kind::EventDeletion, "")
.tag(Tag::parse(["a", &coordinate]).expect("coordinate tag"))
.custom_created_at(Timestamp::from(identity_time + 2))
.sign_with_keys(&owner)
.expect("sign deletion");
let deleted = db
.apply_project_deletion_event(
community,
&deletion,
owner_bytes.as_slice(),
"shared",
None,
)
.await
.expect("delete Project");
assert_eq!(deleted.status, ProjectLifecycleStatus::Applied);
assert_eq!(
deleted.snapshot.as_ref().map(|state| state.revision),
Some(6)
);
assert_eq!(
db.apply_project_deletion_event(
community,
&deletion,
owner_bytes.as_slice(),
"shared",
None,
)
.await
.expect("duplicate deletion")
.status,
ProjectLifecycleStatus::Duplicate
);
let same_second_recreation = EventBuilder::new(Kind::Custom(KIND_PROJECT as u16), "stale")
.tags([Tag::parse(["d", "shared"]).expect("d tag")])
.custom_created_at(Timestamp::from(identity_time + 2))
.sign_with_keys(&owner)
.expect("sign same-second recreation");
assert_eq!(
db.apply_project_identity_event(community, &same_second_recreation)
.await
.expect("reject same-second recreation")
.status,
ProjectLifecycleStatus::Superseded
);
assert!(db
.get_event_by_id(community, same_second_recreation.id.as_bytes())
.await
.expect("check same-second recreation")
.is_none());
let recreation = EventBuilder::new(Kind::Custom(KIND_PROJECT as u16), "")
.tags([Tag::parse(["d", "shared"]).expect("d tag")])
.custom_created_at(Timestamp::from(identity_time + 3))
.sign_with_keys(&owner)
.expect("sign recreation");
let recreated = db
.apply_project_identity_event(community, &recreation)
.await
.expect("recreate Project");
assert_eq!(recreated.status, ProjectLifecycleStatus::Applied);
assert_eq!(
recreated.snapshot.as_ref().map(|state| state.revision),
Some(7)
);
let live_identity_deletion = EventBuilder::new(Kind::EventDeletion, "")
.tag(Tag::event(recreation.id))
.custom_created_at(Timestamp::from(identity_time + 4))
.sign_with_keys(&owner)
.expect("sign live identity deletion");
let deleted_by_id = db
.apply_project_deletion_event(
community,
&live_identity_deletion,
owner_bytes.as_slice(),
"shared",
Some(recreation.id.as_bytes()),
)
.await
.expect("delete live identity by id");
assert_eq!(deleted_by_id.status, ProjectLifecycleStatus::Applied);
assert_eq!(
deleted_by_id.snapshot.as_ref().map(|state| state.revision),
Some(8)
);
drop_scratch_db(admin_pool, pool, &scratch_name).await;
}
}

View File

@@ -1102,6 +1102,104 @@ fn count_e_tags(event: &Event) -> usize {
.count()
}
/// Parse a Project coordinate from a NIP-09 `a` target.
///
/// Other target kinds return `None` and continue through generic deletion.
/// Once the kind segment names a Project, malformed coordinates are rejected
/// rather than falling through to the non-atomic generic side effect.
fn project_deletion_coordinate(event: &Event) -> Result<Option<(Vec<u8>, String)>, IngestError> {
let Some(value) = event.tags.iter().find_map(|tag| {
let parts = tag.as_slice();
(parts.first().is_some_and(|part| part == "a") && parts.len() >= 2)
.then(|| parts[1].as_str())
}) else {
return Ok(None);
};
let mut parts = value.splitn(3, ':');
let Some(kind) = parts.next() else {
return Ok(None);
};
if kind != KIND_PROJECT.to_string() {
return Ok(None);
}
let owner = parts.next().unwrap_or_default();
let d_tag = parts.next().unwrap_or_default();
let canonical_owner = owner.len() == 64
&& owner
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
let owner = canonical_owner
.then(|| hex::decode(owner).ok())
.flatten()
.filter(|bytes| bytes.len() == 32)
.ok_or_else(|| {
IngestError::Rejected("invalid: malformed Project deletion coordinate".into())
})?;
if d_tag.is_empty() || d_tag.len() > buzz_db::event::D_TAG_MAX_LEN {
return Err(IngestError::Rejected(
"invalid: malformed Project deletion coordinate".into(),
));
}
Ok(Some((owner, d_tag.to_string())))
}
struct ProjectDeletionTarget {
owner: Vec<u8>,
d_tag: String,
expected_identity_event_id: Option<Vec<u8>>,
}
/// Resolve either NIP-09 target form into the Project coordinate transaction.
///
/// An `a` target deletes the coordinate. An `e` target retains regular NIP-09
/// exact-event semantics: it only affects Project state while that identity is
/// still the live event for the coordinate.
async fn project_deletion_target(
tenant: &TenantContext,
state: &Arc<AppState>,
event: &Event,
) -> Result<Option<ProjectDeletionTarget>, IngestError> {
if let Some((owner, d_tag)) = project_deletion_coordinate(event)? {
return Ok(Some(ProjectDeletionTarget {
owner,
d_tag,
expected_identity_event_id: None,
}));
}
let Some(target_hex) = event.tags.iter().find_map(|tag| {
let parts = tag.as_slice();
(parts.first().is_some_and(|part| part == "e") && parts.len() >= 2)
.then(|| parts[1].as_str())
}) else {
return Ok(None);
};
let Ok(target_id) = hex::decode(target_hex) else {
return Ok(None);
};
if target_id.len() != 32 {
return Ok(None);
}
let Some(target) = state
.db
.get_event_by_id_including_deleted(tenant.community(), &target_id)
.await
.map_err(|error| IngestError::Internal(format!("error: {error}")))?
else {
return Ok(None);
};
if event_kind_u32(&target.event) != KIND_PROJECT {
return Ok(None);
}
let d_tag = buzz_db::event::extract_d_tag(&target.event)
.filter(|value| !value.is_empty() && value.len() <= buzz_db::event::D_TAG_MAX_LEN)
.ok_or_else(|| IngestError::Internal("error: stored Project has invalid d tag".into()))?;
Ok(Some(ProjectDeletionTarget {
owner: target.event.pubkey.to_bytes().to_vec(),
d_tag,
expected_identity_event_id: Some(target.event.id.as_bytes().to_vec()),
}))
}
/// Extract the effective author of a stored event (handles workflow-generated and
/// legacy relay-signed attributed events).
pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::PublicKey) -> Vec<u8> {
@@ -2715,6 +2813,11 @@ async fn ingest_event_inner(
)));
}
}
let project_deletion = if kind_u32 == KIND_DELETION {
project_deletion_target(tenant, state, &event).await?
} else {
None
};
if kind_u32 == KIND_STREAM_MESSAGE_EDIT {
validate_edit_ownership(tenant.community(), &event, state)
@@ -3137,7 +3240,41 @@ async fn ingest_event_inner(
});
}
let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) {
let project_lifecycle = if kind_u32 == KIND_PROJECT {
Some(
state
.db
.apply_project_identity_event(tenant.community(), &event)
.await
.map_err(|error| match error {
buzz_db::DbError::InvalidData(message) => {
IngestError::Rejected(format!("invalid: {message}"))
}
other => IngestError::Internal(format!("error: {other}")),
})?,
)
} else if let Some(target) = project_deletion.as_ref() {
Some(
state
.db
.apply_project_deletion_event(
tenant.community(),
&event,
&target.owner,
&target.d_tag,
target.expected_identity_event_id.as_deref(),
)
.await
.map_err(|error| IngestError::Internal(format!("error: {error}")))?,
)
} else {
None
};
let project_lifecycle_handled = project_lifecycle.is_some();
let (stored_event, was_inserted) = if let Some(result) = project_lifecycle {
let was_inserted = result.was_inserted();
(result.event, was_inserted)
} else if buzz_core::kind::is_replaceable(kind_u32) {
// NIP-16 replaceable event — atomic replace with stale-write protection.
// channel_id is None for global kinds (0, 1, 3) due to step 5b above.
state
@@ -3204,7 +3341,7 @@ async fn ingest_event_inner(
});
}
if crate::handlers::side_effects::is_side_effect_kind(kind_u32) {
if !project_lifecycle_handled && crate::handlers::side_effects::is_side_effect_kind(kind_u32) {
if let Err(e) =
crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state)
.await
@@ -5324,6 +5461,40 @@ mod postgres_tests {
assert!(is_parameterized_replaceable(KIND_PROJECT));
}
#[test]
fn project_deletion_coordinate_is_strict_and_preserves_colons_in_d_tag() {
let owner = "ab".repeat(32);
let coordinate = format!("30621:{owner}:team:platform");
let event = make_event_with_tags(KIND_DELETION, "", &[&["a", &coordinate]]);
assert_eq!(
project_deletion_coordinate(&event).unwrap(),
Some((hex::decode(owner).unwrap(), "team:platform".into()))
);
}
#[test]
fn malformed_project_deletion_never_falls_through_to_generic_side_effects() {
for coordinate in [
"30621:abcd:project".to_string(),
format!("30621:{}:", "ab".repeat(32)),
format!("30621:{}:project", "AB".repeat(32)),
] {
let event = make_event_with_tags(KIND_DELETION, "", &[&["a", &coordinate]]);
assert!(matches!(
project_deletion_coordinate(&event),
Err(IngestError::Rejected(message))
if message.contains("malformed Project deletion coordinate")
));
}
}
#[test]
fn non_project_deletion_keeps_generic_routing() {
let coordinate = format!("30617:{}:repo", "ab".repeat(32));
let event = make_event_with_tags(KIND_DELETION, "", &[&["a", &coordinate]]);
assert_eq!(project_deletion_coordinate(&event).unwrap(), None);
}
/// Drive every case in the shared NIP-MP fixture file against
/// `validate_project_envelope`. All 11 accept cases must pass; all 20
/// reject cases must return an error whose rule is in the case's allowed

View File

@@ -2185,6 +2185,14 @@ async fn handle_a_tag_deletion(
let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key());
match kind_num {
// Project deletion is handled atomically with its relational state in
// ingest. Reaching this generic post-storage side effect would split
// the event acceptance from the authoritative tombstone.
buzz_core::kind::KIND_PROJECT => {
return Err(anyhow::anyhow!(
"Project deletion bypassed the atomic lifecycle handler"
));
}
// kind:30350 revocation is exclusively a higher-generation inactive replacement.
super::push_lease::KIND_PUSH_LEASE => {
tracing::debug!(d_tag, "NIP-09 deletion ignored for push lease");