feat: add selection validation with optimistic locking

Implements Step 5.3 - selection validation for optimistic locking:

- SelectionContext: holds client's filter, resolved_ids, roster_hash, ts_utc
- SelectionDiff: shows added/removed animals on mismatch
- SelectionValidationResult: validation result with diff if applicable
- validate_selection(): re-resolves at ts_utc, compares hashes, returns diff
- SelectionMismatchError: exception for unconfirmed mismatches

Tests cover: hash match, mismatch detection, diff correctness, confirmed bypass,
from_location_id in hash comparison.

🤖 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 15:46:19 +00:00
parent c80d9f7fda
commit e9d3f34994
4 changed files with 625 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
# ABOUTME: Selection system for resolving animal sets from filters.
# ABOUTME: Provides parser, AST, resolver, and hash for animal selection contexts.
# ABOUTME: Provides parser, AST, resolver, hash, and validation for animal selection contexts.
from animaltrack.selection.ast import FieldFilter, FilterAST
from animaltrack.selection.hash import compute_roster_hash
@@ -10,15 +10,27 @@ from animaltrack.selection.resolver import (
resolve_filter,
resolve_selection,
)
from animaltrack.selection.validation import (
SelectionContext,
SelectionDiff,
SelectionMismatchError,
SelectionValidationResult,
validate_selection,
)
__all__ = [
"FieldFilter",
"FilterAST",
"ParseError",
"SelectionContext",
"SelectionDiff",
"SelectionMismatchError",
"SelectionResolverError",
"SelectionResult",
"SelectionValidationResult",
"compute_roster_hash",
"parse_filter",
"resolve_filter",
"resolve_selection",
"validate_selection",
]

View File

@@ -0,0 +1,149 @@
# ABOUTME: Selection validation with optimistic locking for animal operations.
# ABOUTME: Re-resolves at ts_utc, compares hashes, returns diff on mismatch.
from dataclasses import dataclass
from typing import Any
from animaltrack.selection.hash import compute_roster_hash
from animaltrack.selection.parser import parse_filter
from animaltrack.selection.resolver import resolve_filter
@dataclass
class SelectionContext:
"""Context for validating an animal selection.
Contains client's filter, resolved IDs, and hash for comparison.
"""
filter: str # DSL filter string
resolved_ids: list[str] # Client's resolved animal IDs
roster_hash: str # Client's computed hash
ts_utc: int # Point-in-time for resolution
from_location_id: str | None # For move operations (included in hash)
confirmed: bool = False # Override on mismatch
resolver_version: str = "v1" # Fixed version string
@dataclass
class SelectionDiff:
"""Difference between client and server resolved selections.
Used to inform client what changed since their resolution.
"""
added: list[str] # IDs in server resolution but not client
removed: list[str] # IDs in client but not server resolution
server_count: int # Server's resolved count
client_count: int # Client's resolved count
@dataclass
class SelectionValidationResult:
"""Result of selection validation.
valid=True means the selection can proceed (match or confirmed).
valid=False means mismatch detected and not confirmed.
"""
valid: bool # True if match or confirmed
resolved_ids: list[str] # IDs to use for event
roster_hash: str # Hash to use
diff: SelectionDiff | None # None if match, populated if mismatch
class SelectionMismatchError(Exception):
"""Raised when selection validation fails and not confirmed.
Contains the validation result with diff for client to display.
"""
def __init__(self, result: SelectionValidationResult) -> None:
self.result = result
super().__init__("Selection mismatch detected")
def _compute_diff(
client_ids: list[str],
server_ids: list[str],
) -> SelectionDiff:
"""Compute difference between client and server resolved IDs.
Args:
client_ids: IDs from client's resolution.
server_ids: IDs from server's resolution.
Returns:
SelectionDiff with added, removed, and counts.
"""
client_set = set(client_ids)
server_set = set(server_ids)
added = sorted(server_set - client_set)
removed = sorted(client_set - server_set)
return SelectionDiff(
added=added,
removed=removed,
server_count=len(server_ids),
client_count=len(client_ids),
)
def validate_selection(
db: Any,
context: SelectionContext,
) -> SelectionValidationResult:
"""Validate client selection against server resolution at ts_utc.
Re-resolves the filter at ts_utc, computes roster hash, and compares
with client's hash. Returns valid=True if hashes match or if
confirmed=True. Returns valid=False with diff if mismatch and not
confirmed.
Args:
db: Database connection.
context: SelectionContext with client's filter, IDs, and hash.
Returns:
SelectionValidationResult with validation status and diff if applicable.
"""
# Parse and resolve filter at ts_utc
filter_ast = parse_filter(context.filter)
resolution = resolve_filter(db, filter_ast, context.ts_utc)
# Compute server's hash (including from_location_id if provided)
server_hash = compute_roster_hash(
resolution.animal_ids,
context.from_location_id,
)
# Compare hashes
if server_hash == context.roster_hash:
# Match - proceed with client's IDs
return SelectionValidationResult(
valid=True,
resolved_ids=context.resolved_ids,
roster_hash=context.roster_hash,
diff=None,
)
# Mismatch - compute diff
diff = _compute_diff(context.resolved_ids, resolution.animal_ids)
if context.confirmed:
# Client confirmed mismatch - trust their IDs
return SelectionValidationResult(
valid=True,
resolved_ids=context.resolved_ids,
roster_hash=context.roster_hash,
diff=diff,
)
# Mismatch not confirmed - return invalid with server's resolution
return SelectionValidationResult(
valid=False,
resolved_ids=resolution.animal_ids,
roster_hash=server_hash,
diff=diff,
)