"""Answer-library freshness — mark stale, never auto-rewrite. The library already knew when an answer was DUE for review (``review_interval_months``). It had no way to know the answer had become WRONG. An entry claiming "MFA is enforced on all privileged accounts" sits quietly until its next review cycle after the MFA control goes red — and gets sent to a customer in the meantime. Two staleness reasons, in priority order: * ``backing_control_failing`` — a control this answer rests on is not passing. The claim may now be false, which is the urgent case. * ``review_interval_elapsed`` — nothing is known to be wrong, but nobody has looked in longer than the entry's own declared interval. **Marking only. No automatic re-drafting.** Re-drafting would push AI text nobody has read into a document that goes to a customer, and the library's standing rule is that drafting may be automatic while sending is always human. A stale flag turns "someone should look at this" into a visible signal; it does not decide anything on the reviewer's behalf. """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession STALE_CONTROL_FAILING = "backing_control_failing" STALE_INTERVAL_ELAPSED = "review_interval_elapsed" # Only ``fail`` means "the claim may now be false". ``unknown``, # ``not_configured`` and ``stale`` mean nobody has established anything — # treating them as the urgent tier would, on first deploy, mark every # answer backed by an unevaluated control as possibly-wrong and bury the # handful whose control actually went red. That is exactly what the # two-tier priority exists to prevent. # # Referenced from the catalogue rather than spelled as a literal: the # vocabulary is pass / fail / stale / not_configured / unknown, and a # hand-typed list here would drift the moment a sixth status appears — # silently, and in the direction of not reporting. def _failing_statuses() -> tuple[str, ...]: from app.domains.auditor import compliance_catalog as cat # noqa: PLC0415 return (cat.STATUS_FAIL,) async def evaluate_answer_freshness(db: AsyncSession) -> dict[str, Any]: """Recompute every active entry's staleness. Idempotent.""" from app.domains.auditor.models import ( AuditorAnswerLibraryEntry, ComplianceControl, ComplianceControlStatus, ) now = datetime.now(UTC) failing = { str(key) for (key,) in (await db.execute( select(ComplianceControl.key) .join( ComplianceControlStatus, ComplianceControlStatus.control_id == ComplianceControl.id, ) .where( ComplianceControlStatus.status.in_(_failing_statuses()), # A disabled control is not evaluated, so its status never # returns to ``pass`` — an answer backed by one would have # been marked urgent permanently. ComplianceControl.enabled.is_(True), ) )).all() } entries = (await db.execute( select(AuditorAnswerLibraryEntry).where( AuditorAnswerLibraryEntry.status == "active", ) )).scalars().all() counts = {"checked": 0, "marked": 0, "cleared": 0, "unchanged": 0} for entry in entries: counts["checked"] += 1 reason = _stale_reason(entry, failing_controls=failing, now=now) if reason == entry.stale_reason: counts["unchanged"] += 1 continue if reason is None: # Cleared: the control recovered, or someone reviewed it. entry.stale_reason = None entry.stale_since = None counts["cleared"] += 1 else: entry.stale_reason = reason # Preserve the original date when the reason merely escalates # from "due" to "wrong" — how long it has been stale is the # number a reviewer triages on. entry.stale_since = entry.stale_since or now counts["marked"] += 1 # Flush so the marks are durable for any reader in this transaction; # the caller still owns the commit (the scheduled-job runner does it). await db.flush() return counts def _stale_reason( entry: Any, *, failing_controls: set[str], now: datetime, ) -> str | None: """Failing-control beats interval: one is "possibly wrong", the other is only "nobody has looked lately".""" backing = entry.backing_control_keys or [] if any(str(key) in failing_controls for key in backing): return STALE_CONTROL_FAILING interval = entry.review_interval_months or 0 if interval <= 0: return None last = entry.last_reviewed_at if last is None: # Never reviewed. Treated as due rather than as failing: nothing # is known to be wrong, and reporting it as wrong would bury the # entries that genuinely are. return STALE_INTERVAL_ELAPSED if last.tzinfo is None: last = last.replace(tzinfo=UTC) if now >= _add_months(last, interval): return STALE_INTERVAL_ELAPSED return None def _add_months(moment: datetime, months: int) -> datetime: """Add whole months, clamping the day to the target month's length. Hand-rolled rather than pulling in ``dateutil``: it is only a transitive dependency here and has no type stubs, so relying on it would mean either an untyped import or a lockfile change for four lines of arithmetic. """ total = moment.month - 1 + months year = moment.year + total // 12 month = total % 12 + 1 # 31 Jan + 1 month is 28/29 Feb, not an error. last_day = _days_in_month(year, month) return moment.replace( year=year, month=month, day=min(moment.day, last_day), ) def _days_in_month(year: int, month: int) -> int: import calendar return calendar.monthrange(year, month)[1] __all__ = [ "STALE_CONTROL_FAILING", "STALE_INTERVAL_ELAPSED", "evaluate_answer_freshness", ]