desktop: build private agent tombstones

Add a pure owner-signed deleted aggregate builder that advances verified relay authority evidence without carrying active projections, with codec round-trip and invalid-input coverage.

Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-05 11:40:11 -06:00
committed by Carl
parent 4da22a7635
commit aa7efe0e32
2 changed files with 97 additions and 0 deletions

View File

@@ -157,6 +157,42 @@ pub struct PromotionEvidence {
pub definition_revision: u64,
}
/// Build the next minimal deleted aggregate from verified relay-head evidence.
///
/// A tombstone carries no public projections or private active body. It advances
/// the same CAS chain by exactly one and is signed by the owner, so deletion can
/// use the aggregate route rather than legacy kind:5 mutation.
#[allow(dead_code)] // Consumed by the aggregate deletion driver in the next slice.
pub fn build_tombstone_event(
owner_keys: &Keys,
agent_pubkey: &str,
current_generation: u64,
current_private_event_id: &str,
timestamp: &str,
created_at: u64,
) -> Result<Event, MigrationError> {
let agent = parse_pubkey("agent_pubkey", agent_pubkey)?;
let previous = nostr::EventId::from_hex(current_private_event_id)
.map_err(|e| MigrationError::InvalidInput(format!("private event id: {e}")))?;
let generation = current_generation
.checked_add(1)
.ok_or_else(|| MigrationError::InvalidInput("aggregate generation overflow".into()))?;
let payload = Payload {
format: pma::FORMAT.to_string(),
version: pma::VERSION,
agent_pubkey: agent.to_hex(),
owner_pubkey: owner_keys.public_key().to_hex(),
generation,
previous_event_id: Some(previous.to_hex()),
state: State::Deleted,
updated_at: timestamp.to_string(),
active: None,
deleted_at: Some(timestamp.to_string()),
extensions: Default::default(),
};
pma::build_event(owner_keys, &payload, created_at).map_err(MigrationError::from)
}
/// Build a signed, inert kind:30179 migration candidate for one agent.
///
/// Assembles the encrypted aggregate payload from the record + keys + exact

View File

@@ -447,6 +447,67 @@ fn verify_rejects_wrong_definition_revision() {
assert!(matches!(err, MigrationError::VerificationFailed(_)));
}
#[test]
fn tombstone_advances_verified_head_without_active_payload() {
let owner = Keys::generate();
let agent = Keys::generate();
let previous = EventBuilder::new(Kind::TextNote, "previous")
.sign_with_keys(&owner)
.unwrap();
let timestamp = "2026-08-05T18:00:00Z";
let event = build_tombstone_event(
&owner,
&agent.public_key().to_hex(),
7,
&previous.id.to_hex(),
timestamp,
CREATED_AT,
)
.unwrap();
let (envelope, payload) =
buzz_core_pkg::private_managed_agent::validate_and_decrypt(&event, &owner).unwrap();
assert_eq!(envelope.generation, 8);
assert_eq!(
envelope.previous_event_id.map(|id| id.to_hex()),
Some(previous.id.to_hex())
);
assert_eq!(
payload.state,
buzz_core_pkg::private_managed_agent::State::Deleted
);
assert!(payload.active.is_none());
assert_eq!(payload.deleted_at.as_deref(), Some(timestamp));
assert_eq!(payload.updated_at, timestamp);
}
#[test]
fn tombstone_rejects_invalid_coordinate_or_generation_overflow() {
let owner = Keys::generate();
assert!(matches!(
build_tombstone_event(
&owner,
"invalid",
1,
&"11".repeat(32),
"2026-08-05T18:00:00Z",
CREATED_AT
),
Err(MigrationError::InvalidInput(_))
));
assert!(matches!(
build_tombstone_event(
&owner,
&Keys::generate().public_key().to_hex(),
u64::MAX,
&"11".repeat(32),
"2026-08-05T18:00:00Z",
CREATED_AT,
),
Err(MigrationError::InvalidInput(_))
));
}
#[test]
fn verify_rejects_stale_generation_head() {
let fx = Fixture::new();