"""Typed, bounded query and response models for the operator audit timeline.""" from __future__ import annotations from datetime import timedelta from uuid import UUID from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator AUDIT_TIMELINE_MAX_PAGE_SIZE = 100 AUDIT_TIMELINE_MAX_TIME_RANGE = timedelta(days=366) class AuditTimelineRequest(BaseModel): """Validated filters for one bounded page of audit events. Text filters are case-insensitive substring matches. A time filter must specify both ends, and its window is limited to one year plus one leap day. """ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) principal: str | None = Field(default=None, min_length=1, max_length=255) action: str | None = Field(default=None, min_length=1, max_length=128) resource: str | None = Field(default=None, min_length=1, max_length=128) outcome: str | None = Field(default=None, min_length=1, max_length=32) occurred_after: AwareDatetime | None = None occurred_before: AwareDatetime | None = None correlation_id: str | None = Field(default=None, min_length=1, max_length=64) limit: int = Field(default=50, ge=1, le=AUDIT_TIMELINE_MAX_PAGE_SIZE) cursor: str | None = Field(default=None, min_length=1, max_length=256) @model_validator(mode="after") def validate_time_window(self) -> AuditTimelineRequest: if (self.occurred_after is None) != (self.occurred_before is None): raise ValueError("occurred_after and occurred_before must be supplied together") if self.occurred_after is not None and self.occurred_before is not None: if self.occurred_after >= self.occurred_before: raise ValueError("occurred_after must be earlier than occurred_before") if self.occurred_before - self.occurred_after > AUDIT_TIMELINE_MAX_TIME_RANGE: raise ValueError("audit time range exceeds the 366-day maximum") return self class AuditTimelineEvent(BaseModel): """Safe event fields returned to the operator console.""" model_config = ConfigDict(frozen=True) id: UUID sequence: int = Field(ge=1) occurred_at: AwareDatetime correlation_id: str actor_type: str actor_id: str action: str resource_type: str resource_id: str | None outcome: str class AuditTimelineResponse(BaseModel): """One page of audit events and an opaque keyset cursor for the next page.""" model_config = ConfigDict(frozen=True) events: list[AuditTimelineEvent] next_cursor: str | None limit: int = Field(ge=1, le=AUDIT_TIMELINE_MAX_PAGE_SIZE)