Skip to content

symfonic.services.conversation.notification

notification

Tenant notification and export policy for expiring state.

Expiry is only "explicit rather than silent" if somebody is told before it happens and can take their data with them. This module is the tenant-facing half of the horizon: who is holding state that is about to expire, what they should be told, and what they may export while the window is still open.

The export cut-off is deliberate. Offering export after the cutoff would make the horizon advisory, and an advisory horizon never actually retires anything.

ExpiryNotice dataclass

ExpiryNotice(tenant_id: str, thread_id: str, checkpoint_id: str, deadline: datetime, support_route: str)

What one tenant is told, carrying no conversation content.

ExportReceipt dataclass

ExportReceipt(tenant_id: str, thread_id: str, checkpoint_id: str, receipt: str, at: datetime)

Proof that a tenant's expiring state was handed back before expiry.

TenantNotificationPolicy

TenantNotificationPolicy(*, registry: CheckpointRegistry, horizon: CalendarHorizon, export: Callable[[CheckpointRef], str] | None = None)

Decides who is notified, and what may still be exported.

Source code in src/symfonic/services/conversation/notification.py
def __init__(
    self,
    *,
    registry: CheckpointRegistry,
    horizon: CalendarHorizon,
    export: Callable[[CheckpointRef], str] | None = None,
) -> None:
    self._registry = registry
    self._horizon = horizon
    self._export = export

expiring

expiring(*, now: datetime) -> tuple[CheckpointRef, ...]

Refs the horizon would refuse once the cutoff passes.

Source code in src/symfonic/services/conversation/notification.py
def expiring(self, *, now: datetime) -> tuple[CheckpointRef, ...]:
    """Refs the horizon would refuse once the cutoff passes."""
    return tuple(
        ref
        for ref in self._registry.all_refs()
        if self._horizon.decide(ref, now=now).verdict == "migration_required"
    )

export_expiring

export_expiring(*, now: datetime) -> tuple[ExportReceipt, ...]

Export everything still inside the window. Nothing after it.

Same attribution rule as :meth:notices, for a stronger reason: a receipt carries the state itself, so handing one to a guessed tenant is a cross-tenant data release rather than a mis-addressed warning.

Source code in src/symfonic/services/conversation/notification.py
def export_expiring(self, *, now: datetime) -> tuple[ExportReceipt, ...]:
    """Export everything still inside the window. Nothing after it.

    Same attribution rule as :meth:`notices`, for a stronger reason: a
    receipt carries the state itself, so handing one to a guessed tenant
    is a cross-tenant *data* release rather than a mis-addressed warning.
    """
    if self._export is None or now >= self._horizon.cutoff:
        return ()
    return tuple(
        ExportReceipt(
            tenant_id=tenant_id,
            thread_id=ref.thread_id,
            checkpoint_id=ref.checkpoint_id,
            receipt=self._export(ref),
            at=now,
        )
        for ref, tenant_id in self._addressable(now=now)
    )

notices

notices(*, now: datetime) -> tuple[ExpiryNotice, ...]

Notices for the current moment; empty outside the notice window.

Notifying earlier would train tenants to ignore the notice, and notifying after the cutoff would be an obituary, not a warning. Refs listed by :meth:unattributed are skipped: a notice addressed to a guessed tenant is a cross-tenant disclosure, not a warning.

Source code in src/symfonic/services/conversation/notification.py
def notices(self, *, now: datetime) -> tuple[ExpiryNotice, ...]:
    """Notices for the current moment; empty outside the notice window.

    Notifying earlier would train tenants to ignore the notice, and
    notifying after the cutoff would be an obituary, not a warning.
    Refs listed by :meth:`unattributed` are skipped: a notice addressed to
    a guessed tenant is a cross-tenant disclosure, not a warning.
    """
    if not self._horizon.in_notice_window(now=now):
        return ()
    return tuple(
        ExpiryNotice(
            tenant_id=tenant_id,
            thread_id=ref.thread_id,
            checkpoint_id=ref.checkpoint_id,
            deadline=self._horizon.cutoff,
            support_route=self._horizon.support_route,
        )
        for ref, tenant_id in self._addressable(now=now)
    )

unattributed

unattributed(*, now: datetime) -> tuple[CheckpointRef, ...]

Expiring refs whose owning tenant cannot be named.

A thread key the legacy path wrote under a separator-bearing tenant parses to a prefix of that tenant, so addressing a notice from it would hand one tenant another tenant's thread and checkpoint ids. Those refs are held back here rather than mis-addressed โ€” and rather than dropped, because an expiry nobody can be told about is exactly the silent expiry the horizon exists to prevent. The operator route is to record the owning tenant on the ref (tenant_id), after which it notifies normally.

Source code in src/symfonic/services/conversation/notification.py
def unattributed(self, *, now: datetime) -> tuple[CheckpointRef, ...]:
    """Expiring refs whose owning tenant cannot be named.

    A thread key the legacy path wrote under a separator-bearing tenant
    parses to a *prefix* of that tenant, so addressing a notice from it
    would hand one tenant another tenant's thread and checkpoint ids.
    Those refs are held back here rather than mis-addressed โ€” and rather
    than dropped, because an expiry nobody can be told about is exactly
    the silent expiry the horizon exists to prevent. The operator route is
    to record the owning tenant on the ref (``tenant_id``), after which it
    notifies normally.
    """
    return tuple(
        ref for ref in self.expiring(now=now) if not ref.attribution_is_certain
    )