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:
Wes
2026-08-05 11:47:07 -06:00
committed by Carl
parent 7908cd066e
commit 82dc81625e
2 changed files with 167 additions and 0 deletions

View File

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

View File

@@ -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();