feat: add event deletion with tombstone creation and cascade rules

Implement event deletion per spec §10 with role-based rules:
- Recorder can delete own events if no dependents exist
- Admin can cascade delete (tombstone target + all dependents)
- All deletions create immutable tombstone records

New modules:
- events/dependencies.py: find events depending on a target via shared animals
- events/delete.py: delete_event() with projection reversal

DependentEventsError exception added for 409 Conflict responses.
E2E test #6 implemented per spec §21.6.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-29 18:44:13 +00:00
parent f733a067e2
commit 282d3d0f5a
5 changed files with 1405 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
# ABOUTME: Event deletion functionality with tombstone creation and cascade rules.
# ABOUTME: Implements recorder vs admin deletion rules per spec section 10.
import time
from typing import Any
from animaltrack.events.dependencies import find_dependent_events
from animaltrack.events.exceptions import (
DependentEventsError,
EventNotFoundError,
EventTombstonedError,
)
from animaltrack.events.processor import revert_event
from animaltrack.events.store import EventStore
from animaltrack.id_gen import generate_id
from animaltrack.models.events import Event
from animaltrack.projections import ProjectionRegistry
def delete_event(
db: Any,
event_store: EventStore,
event_id: str,
actor: str,
role: str,
cascade: bool = False,
reason: str | None = None,
registry: ProjectionRegistry | None = None,
) -> list[str]:
"""Delete an event by creating a tombstone.
Steps:
1. Retrieve the event (raise if not found)
2. Check if already tombstoned (raise if so)
3. Find dependent events
4. Apply role-based rules:
- Recorder: Cannot delete if dependents exist
- Admin: Can delete with cascade to delete dependents
5. Revert projections for all events to be deleted
6. Create tombstone(s)
Args:
db: Database connection.
event_store: EventStore instance.
event_id: The ULID of the event to delete.
actor: Username of who is making the deletion.
role: Either "recorder" or "admin".
cascade: If True and role is admin, delete dependents too.
reason: Optional reason for the deletion.
registry: Optional projection registry for updating projections.
Returns:
List of event IDs that were tombstoned.
Raises:
EventNotFoundError: If the event doesn't exist.
EventTombstonedError: If the event has already been deleted.
DependentEventsError: If the event has dependents and cascade is not allowed.
"""
# Get the target event
target_event = event_store.get_event(event_id)
if target_event is None:
raise EventNotFoundError(f"Event {event_id} not found")
# Check if already tombstoned
if event_store.is_tombstoned(event_id):
raise EventTombstonedError(f"Event {event_id} has already been deleted")
# Find dependent events
dependents = find_dependent_events(db, event_store, event_id)
# Apply role-based rules
if dependents:
if role == "recorder":
raise DependentEventsError(
f"Cannot delete event {event_id}: it has {len(dependents)} dependent event(s)",
dependents,
)
elif role == "admin" and not cascade:
raise DependentEventsError(
f"Cannot delete event {event_id}: it has {len(dependents)} dependent event(s). "
"Use cascade=True to delete all dependents.",
dependents,
)
# Build the list of events to delete
# Delete in reverse chronological order (newest first) to properly revert projections
events_to_delete: list[Event] = [target_event]
if cascade and dependents:
# Sort dependents by ts_utc DESC (newest first) for proper revert order
events_to_delete = sorted(dependents, key=lambda e: e.ts_utc, reverse=True) + [target_event]
# Revert projections for all events (newest first)
if registry is not None:
for event in events_to_delete:
revert_event(event, registry)
# Create tombstones
deleted_at_utc = int(time.time() * 1000)
tombstoned_ids = []
for event in events_to_delete:
tombstone_id = generate_id()
tombstone_reason = reason
if event.id != event_id:
tombstone_reason = f"cascade from {event_id}" + (f": {reason}" if reason else "")
db.execute(
"""INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id, reason)
VALUES (?, ?, ?, ?, ?)""",
(tombstone_id, deleted_at_utc, actor, event.id, tombstone_reason),
)
tombstoned_ids.append(event.id)
return tombstoned_ids

View File

@@ -0,0 +1,78 @@
# ABOUTME: Dependency detection for event deletion.
# ABOUTME: Finds events that depend on a target event via shared animals.
import json
from typing import Any
from animaltrack.events.store import EventStore
from animaltrack.models.events import Event
def find_dependent_events(db: Any, event_store: EventStore, event_id: str) -> list[Event]:
"""Find events that depend on the given event.
An event B depends on event A if:
- B references at least one animal that A also references
- B's ts_utc > A's ts_utc (B happened after A)
- B is not tombstoned
This is used to determine if an event can be safely deleted.
If an event has dependents, a recorder cannot delete it (409 Conflict).
An admin can delete it with cascade=True to delete all dependents.
Args:
db: Database connection.
event_store: EventStore instance for fetching events.
event_id: The ULID of the target event.
Returns:
List of Event objects that depend on the target event,
ordered by ts_utc ASC (oldest first).
"""
# Get the target event
target_event = event_store.get_event(event_id)
if target_event is None:
return []
# Get animal_ids linked to the target event
target_animals = db.execute(
"SELECT animal_id FROM event_animals WHERE event_id = ?",
(event_id,),
).fetchall()
if not target_animals:
return []
target_animal_ids = [row[0] for row in target_animals]
# Find other events that reference any of these animals and are:
# - After the target event (ts_utc > target.ts_utc)
# - Not the target event itself
# - Not tombstoned
placeholders = ",".join("?" for _ in target_animal_ids)
query = f"""
SELECT DISTINCT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version
FROM events e
JOIN event_animals ea ON ea.event_id = e.id
WHERE ea.animal_id IN ({placeholders})
AND e.ts_utc > ?
AND e.id != ?
AND e.id NOT IN (SELECT target_event_id FROM event_tombstones)
ORDER BY e.ts_utc ASC, e.id ASC
"""
params = (*target_animal_ids, target_event.ts_utc, event_id)
rows = db.execute(query, params).fetchall()
return [
Event(
id=row[0],
type=row[1],
ts_utc=row[2],
actor=row[3],
entity_refs=json.loads(row[4]),
payload=json.loads(row[5]),
version=row[6],
)
for row in rows
]

View File

@@ -24,3 +24,21 @@ class EventNotFoundError(Exception):
class EventTombstonedError(Exception):
"""Raised when attempting to edit or access a tombstoned (deleted) event."""
class DependentEventsError(Exception):
"""Raised when attempting to delete an event that has dependent events.
Contains the list of dependent events so the caller can inform the user
or request cascade deletion with admin privileges.
"""
def __init__(self, message: str, dependent_events: list) -> None:
"""Initialize with message and list of dependent events.
Args:
message: Error message.
dependent_events: List of Event objects that depend on the target.
"""
super().__init__(message)
self.dependent_events = dependent_events