mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
desktop: verify PMA deletion read-back
Require deleted aggregate responses to return the exact retained tombstone, echo its generation and event ID, and omit all active projections before an attempt may be acknowledged. Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -157,6 +157,18 @@ pub struct PromotionEvidence {
|
||||
pub definition_revision: u64,
|
||||
}
|
||||
|
||||
/// Proof that a deleted aggregate request was committed and read back exactly.
|
||||
///
|
||||
/// Deleted heads do not carry public projections or an active private body, so
|
||||
/// their verification contract is intentionally smaller than promotion's but
|
||||
/// just as strict about the submitted event and CAS metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeletionEvidence {
|
||||
pub head_event_id: String,
|
||||
pub generation: u64,
|
||||
pub previous_event_id: String,
|
||||
}
|
||||
|
||||
/// Build the next minimal deleted aggregate from verified relay-head evidence.
|
||||
///
|
||||
/// A tombstone carries no public projections or private active body. It advances
|
||||
@@ -470,6 +482,69 @@ pub fn verify_promotion(
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a relay read-back for an exact deleted aggregate request.
|
||||
///
|
||||
/// The submitted event is the immutable retry input retained by Desktop. The
|
||||
/// response must return that byte-identical event and echo its deleted CAS head;
|
||||
/// deleted heads must not grow public projections or a definition revision.
|
||||
pub fn verify_deletion(
|
||||
submitted_event: &Event,
|
||||
response: &AggregateResponse,
|
||||
owner_keys: &Keys,
|
||||
) -> Result<DeletionEvidence, MigrationError> {
|
||||
let (envelope, payload) = pma::validate_and_decrypt(submitted_event, owner_keys)
|
||||
.map_err(|e| MigrationError::VerificationFailed(format!("submitted tombstone: {e}")))?;
|
||||
|
||||
let previous_event_id = payload.previous_event_id.clone().ok_or_else(|| {
|
||||
MigrationError::VerificationFailed("submitted deletion has no predecessor".into())
|
||||
})?;
|
||||
if payload.state != State::Deleted || payload.active.is_some() || payload.deleted_at.is_none() {
|
||||
return Err(MigrationError::VerificationFailed(
|
||||
"submitted aggregate is not a valid deletion".into(),
|
||||
));
|
||||
}
|
||||
if !response.accepted {
|
||||
return Err(MigrationError::VerificationFailed(
|
||||
"relay did not accept aggregate deletion".into(),
|
||||
));
|
||||
}
|
||||
if &response.private_event != submitted_event {
|
||||
return Err(MigrationError::VerificationFailed(
|
||||
"read-back deletion head differs from submitted event".into(),
|
||||
));
|
||||
}
|
||||
verify_eq(
|
||||
"response.event_id",
|
||||
&response.event_id,
|
||||
&submitted_event.id.to_hex(),
|
||||
)?;
|
||||
verify_eq(
|
||||
"response.generation",
|
||||
&response.generation,
|
||||
&payload.generation,
|
||||
)?;
|
||||
verify_eq("response.state", &response.state.as_str(), &"deleted")?;
|
||||
if response.definition_event.is_some()
|
||||
|| response.instance_event.is_some()
|
||||
|| response.definition_revision.is_some()
|
||||
{
|
||||
return Err(MigrationError::VerificationFailed(
|
||||
"deleted read-back unexpectedly contains active projections".into(),
|
||||
));
|
||||
}
|
||||
verify_eq(
|
||||
"envelope.generation",
|
||||
&envelope.generation,
|
||||
&payload.generation,
|
||||
)?;
|
||||
|
||||
Ok(DeletionEvidence {
|
||||
head_event_id: submitted_event.id.to_hex(),
|
||||
generation: payload.generation,
|
||||
previous_event_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_binding_definition(
|
||||
source: &ActivePayload,
|
||||
roundtrip: &ActivePayload,
|
||||
|
||||
@@ -508,6 +508,98 @@ fn tombstone_rejects_invalid_coordinate_or_generation_overflow() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_deleted_readback_accepts_exact_tombstone_and_rejects_active_residue() {
|
||||
let owner = Keys::generate();
|
||||
let agent = Keys::generate();
|
||||
let previous_id = "11".repeat(32);
|
||||
let event = build_tombstone_event(
|
||||
&owner,
|
||||
&agent.public_key().to_hex(),
|
||||
7,
|
||||
&previous_id,
|
||||
"2026-08-05T18:00:00Z",
|
||||
CREATED_AT,
|
||||
)
|
||||
.unwrap();
|
||||
let mut response = AggregateResponse {
|
||||
event_id: event.id.to_hex(),
|
||||
generation: 8,
|
||||
state: "deleted".into(),
|
||||
accepted: true,
|
||||
inserted: true,
|
||||
private_event: event.clone(),
|
||||
definition_event: None,
|
||||
instance_event: None,
|
||||
definition_revision: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
verify_deletion(&event, &response, &owner).unwrap(),
|
||||
DeletionEvidence {
|
||||
head_event_id: event.id.to_hex(),
|
||||
generation: 8,
|
||||
previous_event_id: previous_id,
|
||||
}
|
||||
);
|
||||
|
||||
response.definition_revision = Some(7);
|
||||
assert!(matches!(
|
||||
verify_deletion(&event, &response, &owner),
|
||||
Err(MigrationError::VerificationFailed(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_deleted_readback_rejects_swapped_or_drifted_head() {
|
||||
let owner = Keys::generate();
|
||||
let agent = Keys::generate();
|
||||
let event = build_tombstone_event(
|
||||
&owner,
|
||||
&agent.public_key().to_hex(),
|
||||
2,
|
||||
&"22".repeat(32),
|
||||
"2026-08-05T18:00:00Z",
|
||||
CREATED_AT,
|
||||
)
|
||||
.unwrap();
|
||||
let other = build_tombstone_event(
|
||||
&owner,
|
||||
&agent.public_key().to_hex(),
|
||||
2,
|
||||
&"22".repeat(32),
|
||||
"2026-08-05T18:00:01Z",
|
||||
CREATED_AT + 1,
|
||||
)
|
||||
.unwrap();
|
||||
let base = AggregateResponse {
|
||||
event_id: event.id.to_hex(),
|
||||
generation: 3,
|
||||
state: "deleted".into(),
|
||||
accepted: true,
|
||||
inserted: false,
|
||||
private_event: event.clone(),
|
||||
definition_event: None,
|
||||
instance_event: None,
|
||||
definition_revision: None,
|
||||
};
|
||||
|
||||
for mutate in 0..4 {
|
||||
let mut response = base.clone();
|
||||
match mutate {
|
||||
0 => response.private_event = other.clone(),
|
||||
1 => response.event_id = other.id.to_hex(),
|
||||
2 => response.generation += 1,
|
||||
3 => response.accepted = false,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(matches!(
|
||||
verify_deletion(&event, &response, &owner),
|
||||
Err(MigrationError::VerificationFailed(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_stale_generation_head() {
|
||||
let fx = Fixture::new();
|
||||
|
||||
Reference in New Issue
Block a user