feat(s52): Notifier protocol + console notifier + MAF-free registration

This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 19:45:17 +02:00
commit d5b583349a
4 changed files with 196 additions and 1 deletions

View file

@ -0,0 +1,48 @@
"""S5.2 — Varsling (notification): B11 notify-stub → leverbare Notifiers (roadmap E, målbilde §3).
``run_project`` takes ``notify: Callable[[Verdict], None] | None`` and calls ``notify(verdict)``
after the verdict is captured this module supplies the deliverable implementations behind that
seam (the seam itself stays untouched). ``Notifier`` is the declared contract: a
``__call__``-shaped structural superset of the seam's ``Callable``, so any plain callable still
satisfies it.
**MAF-free** (D7-portable): pure stdlib. ``Verdict`` is imported ONLY under ``TYPE_CHECKING`` and
every runtime access is duck-typed so importing this module never pulls in ``agent_framework``.
Registered in ``tests/test_okf.py``'s ``_MAF_FREE_MODULES`` (direct-import guard
``test_okf_is_maf_free``) and probed transitively by
``tests/test_notify_loadbearing.py::test_notify_transitive_import_graph_is_maf_free``.
"""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Protocol, TextIO, runtime_checkable
if TYPE_CHECKING: # verdicts imports agent_framework — keep it out of the runtime import graph
from portfolio_optimiser.verdicts import Verdict
@runtime_checkable
class Notifier(Protocol):
"""The declared notify contract (B11): structurally a superset of the run-path seam
``Callable[[Verdict], None]`` ``isinstance(fn, Notifier)`` holds for any plain callable."""
def __call__(self, verdict: Verdict) -> None: ...
class NotifyError(RuntimeError):
"""A notifier failed to deliver (e.g. webhook transport failure)."""
class NotifyRefused(Exception):
"""Fail-closed egress-gate refusal (mirrors ``verdicts.PromotionRefused``)."""
class ConsoleNotifier:
"""One-line verdict summary to a text stream (default stdout). Never receives a URL."""
def __init__(self, stream: TextIO = sys.stdout) -> None:
self._stream = stream
def __call__(self, verdict: Verdict) -> None:
print(f"[notify] verdict {verdict.id} decision={verdict.decision}", file=self._stream)