"""Who should act on an exception. One rule governs this module: **never invent an owner.** A row whose owner cannot be resolved keeps ``owner_person_id = NULL`` and ``owner_source = 'none'``, and the workbench shows it as unowned. That is a finding worth seeing — the same reasoning the system registry already applies to its own ownerless systems. The tempting shortcut is to default unowned rows to "IT" so nothing looks orphaned. It is the wrong trade twice over: it hides the gap, and it makes the owner column meaningless the moment most rows carry the same fallback. Fan-out to the IT admin group is a *notification audience*, decided at send time by the existing category routing — not an ownership claim stored on the row. Resolution order, first hit wins. The order is by **strength of the link**, not by how appealing the answer is: 1. **The software this failure is about.** Provisioning failures carry a ``software_id`` outright, and the catalog already records an owner and an IT contact per package. 2. **The software behind the integration.** ``software_connectors`` binds a catalog row to an integration with real foreign keys, so an integration-scoped failure can reach the same owner. This is a declared relationship, which is why it outranks the next one. 3. **A registered system with the same slug.** Opportunistic, and labelled as such: ``integrations.slug`` and ``registry_systems.slug`` are two independent namespaces that nothing keeps in step. Measured on the local dataset it matched **0 of 5** integrations. It stays because when it does match it is the best answer available (the registry is the platform's curated "who owns this thing", complete with a backup owner for when the primary has left) and because its failure mode is to resolve nothing rather than to resolve somebody wrong. Do not mistake it for a guaranteed link. 4. **Nothing.** Recorded honestly. Every resolved owner is checked for employment: an owner who has left is not an owner. Where a backup exists (registered systems) it is used; otherwise the row falls through to unowned rather than routing work to somebody who cannot receive it. """ from __future__ import annotations import uuid from dataclasses import dataclass from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.person_scope import EMPLOYED_STATUSES from app.domains.asset.models import AssetSoftwareCatalog from app.domains.provisioning.models import SoftwareConnector from app.domains.system_registry.models import RegisteredSystem from app.identity.person_models import IdentityPerson from app.integrations.models import Integration #: ``owner_source`` values. Stored, so they are schema. OWNER_SOURCE_SYSTEM_REGISTRY = "system_registry" OWNER_SOURCE_SYSTEM_BACKUP = "system_backup" OWNER_SOURCE_SOFTWARE_OWNER = "software_owner" OWNER_SOURCE_SOFTWARE_IT = "software_it_contact" OWNER_SOURCE_NONE = "none" OWNER_SOURCES: tuple[str, ...] = ( OWNER_SOURCE_SYSTEM_REGISTRY, OWNER_SOURCE_SYSTEM_BACKUP, OWNER_SOURCE_SOFTWARE_OWNER, OWNER_SOURCE_SOFTWARE_IT, OWNER_SOURCE_NONE, ) @dataclass(frozen=True, slots=True) class ResolvedOwner: person_id: uuid.UUID | None source: str @classmethod def none(cls) -> ResolvedOwner: return cls(person_id=None, source=OWNER_SOURCE_NONE) async def _employed_ids( db: AsyncSession, candidates: list[uuid.UUID | None], ) -> set[uuid.UUID]: """Subset of ``candidates`` who are still on payroll. One query for all candidates — ownership resolution runs per exception during ingest, and a per-candidate round trip would make a 200-exception sweep 400 queries. """ ids = [c for c in candidates if c is not None] if not ids: return set() rows = await db.execute( select(IdentityPerson.id).where( IdentityPerson.id.in_(ids), IdentityPerson.employment_status.in_(sorted(EMPLOYED_STATUSES)), IdentityPerson.deleted_at.is_(None), ), ) return {row[0] for row in rows.all()} async def _owner_from_software( db: AsyncSession, software_id: uuid.UUID, ) -> ResolvedOwner | None: """Catalog owner, then the catalog's IT contact. Employment-checked.""" catalog = ( await db.execute( select(AssetSoftwareCatalog).where( AssetSoftwareCatalog.id == software_id, ).limit(1), ) ).scalar_one_or_none() if catalog is None: return None employed = await _employed_ids( db, [catalog.owner_person_id, catalog.it_contact_person_id], ) if catalog.owner_person_id in employed: return ResolvedOwner( person_id=catalog.owner_person_id, source=OWNER_SOURCE_SOFTWARE_OWNER, ) if catalog.it_contact_person_id in employed: return ResolvedOwner( person_id=catalog.it_contact_person_id, source=OWNER_SOURCE_SOFTWARE_IT, ) return None async def resolve_owner( db: AsyncSession, *, integration: Integration | None, software_id: uuid.UUID | None = None, ) -> ResolvedOwner: """Best deterministic owner for one exception, or nobody. ``integration`` and ``software_id`` are both optional; a source that can supply neither always resolves to :meth:`ResolvedOwner.none`, which is the honest answer for a whole-cron failure that belongs to the platform rather than to a system. """ # 1. The package this failure is directly about. if software_id is not None: resolved = await _owner_from_software(db, software_id) if resolved is not None: return resolved if integration is None: return ResolvedOwner.none() # 2. The package(s) bound to this integration by a real FK chain. # ``ORDER BY created_at`` makes a multi-package integration pick # the same one every run rather than whichever the planner # returned first — an owner that changes between ticks reads as # the ledger being unreliable. bound = ( await db.execute( select(SoftwareConnector.software_id) .where( SoftwareConnector.integration_id == integration.id, SoftwareConnector.is_active.is_(True), ) .order_by(SoftwareConnector.created_at), ) ).scalars().all() for candidate in bound: resolved = await _owner_from_software(db, candidate) if resolved is not None: return resolved # 3. Opportunistic slug match against the system registry. See the # module docstring: this is not a guaranteed link. system = ( await db.execute( select(RegisteredSystem).where( RegisteredSystem.slug == integration.slug, RegisteredSystem.deleted_at.is_(None), ).limit(1), ) ).scalar_one_or_none() if system is not None: employed = await _employed_ids( db, [system.owner_person_id, system.backup_owner_person_id], ) if system.owner_person_id in employed: return ResolvedOwner( person_id=system.owner_person_id, source=OWNER_SOURCE_SYSTEM_REGISTRY, ) if system.backup_owner_person_id in employed: return ResolvedOwner( person_id=system.backup_owner_person_id, source=OWNER_SOURCE_SYSTEM_BACKUP, ) return ResolvedOwner.none()