"""What a collector hands the ingest engine, and what it must be honest about. A collector answers one question: *what is failing in my domain right now.* It does not decide clustering, ownership, severity or lifecycle — those are the engine's, so that adding a sixth source means writing one query, not re-deriving policy. The subtle part is **recovery**, and it is why this module exists rather than collectors simply returning a list. An occurrence recovers when its source row stops failing. Usually the engine can infer that from absence: the collector reports the complete set of currently-failing rows, so anything it previously recorded and did not report this time has recovered. But that inference is only valid if the collector really did scan everything. Two of the five cannot: * A **cron run** is a historical event. A failed run from Tuesday will never stop being a failed run; what makes it stale is a *later successful run of the same job*. Absence proves nothing, so that collector reports recoveries explicitly. * A collector that scans only a **recent window** must not let its window boundary read as recovery for everything older. So a collector declares its own semantics. Getting this wrong in the permissive direction silently marks live problems as fixed, which is worse than never having built the ledger — hence :attr:`CollectorResult.absence_means_recovery` defaults to ``False`` and each collector opts in with a comment saying why it may. """ from __future__ import annotations import uuid from dataclasses import dataclass, field from datetime import datetime from typing import Any @dataclass(slots=True) class Candidate: """One currently-failing source row. ``source_ref_id`` must be stable for the life of the underlying row — it is half of the idempotency key. Use the row's primary key, or for integrations the slug (stable, and what a human recognises). """ source_ref_type: str source_ref_id: str occurred_at: datetime error_text: str | None #: Narrowest deterministic "where" — integration slug, cron handler #: key, sync-target slug. ``""`` when the source is its own scope. scope_key: str = "" #: Per-occurrence structured detail. Vendor code, http status, the #: action attempted. Never credential material — collectors pull #: from already-scrubbed columns. detail: dict[str, Any] | None = None #: The person this failure is *about*, not the person who caused it. person_id: uuid.UUID | None = None #: Implicated integration, when one can be named. Drives the #: credential remedy's blast radius and half of ownership. integration_id: uuid.UUID | None = None #: Software catalog row, for the other half of ownership. software_id: uuid.UUID | None = None #: Can PR3 re-run exactly this row? ``False`` for events that are #: not re-runnable per row (a cron run, a connection test), so the #: disposition UI never offers an action that would do nothing. replayable: bool = False #: Facts — never text — passed to ``rules.compute_severity``. severity_facts: dict[str, Any] = field(default_factory=dict) @dataclass(slots=True) class CollectorResult: """A collector's full answer, including what it may claim about recovery.""" candidates: list[Candidate] #: Refs the collector *asserts* have recovered, with evidence it #: holds and the engine does not (a later successful run, a #: connection test that now passes). Always honoured. recovered_refs: set[str] = field(default_factory=set) #: Opt-in. ``True`` only when ``candidates`` is the complete set of #: currently-failing rows for this source, so an absent ref really #: has stopped failing. absence_means_recovery: bool = False #: When ``absence_means_recovery`` is ``True`` but the scan was #: bounded, occurrences older than this are left alone. ``None`` #: means the scan was genuinely unbounded. absence_window_start: datetime | None = None #: Non-fatal problems hit while collecting, surfaced in the cron's #: result summary. A collector that cannot read its source must say #: so rather than returning an empty list that would read as "all #: clear" and auto-resolve every open row it owns. warnings: list[str] = field(default_factory=list) #: Set when the collector could not complete. The engine then #: ingests the candidates it did get but performs **no** #: absence-based recovery, because a partial scan cannot prove #: absence. This is the guard against a transient read error #: silently closing a whole source's open exceptions. partial: bool = False