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)

42
tests/test_notify.py Normal file
View file

@ -0,0 +1,42 @@
"""S5.2 — Varsling: unit/contract tests for ``notify.py`` (Notifier protocol + notifiers).
Detach-seam (RED-when-detached) twins live in ``tests/test_notify_loadbearing.py``. Tests may
import MAF-tainted modules freely (``verdicts``) only ``notify.py`` itself must stay MAF-free.
"""
from __future__ import annotations
import io
from portfolio_optimiser.notify import ConsoleNotifier, Notifier
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, capture_verdict
def _verdict() -> Verdict:
return capture_verdict(
ProposalFeatures(
affected_codes=frozenset({"05.2", "03.1"}),
measure_type="scope_reduction",
claimed_saving_nok=200_000.0,
description="LED retrofit",
),
"approved",
"feasible within range",
)
def test_plain_callable_satisfies_notifier() -> None:
"""The seam promise: any plain callable satisfies the runtime-checkable ``Notifier`` protocol,
so ``run_project(notify=lambda v: ...)`` stays valid a structural superset of
``Callable[[Verdict], None]``, not a new ABC (needs ``@runtime_checkable``)."""
assert isinstance(lambda v: None, Notifier)
def test_console_emits_id_and_decision() -> None:
"""ConsoleNotifier writes the verdict id + decision to the injected stream (never a URL)."""
stream = io.StringIO()
verdict = _verdict()
ConsoleNotifier(stream=stream)(verdict)
out = stream.getvalue()
assert verdict.id in out
assert verdict.decision in out

View file

@ -0,0 +1,105 @@
"""S5.2 — Varsling: load-bearing detach seams for ``notify.py``. Each test goes RED when the
guarded mechanism is removed; a bare happy-path pass would not catch the regression.
Seams pinned here:
- **MAF-free registration** the direct-import guard (``test_okf_is_maf_free``) actually scans
``notify.py`` (otherwise the MAF-free claim is green-but-dead);
- **transitive import-graph MAF-freedom** a TYPE_CHECKING-aware static BFS over notify's
first-party RUNTIME import edges (model: the S51 hitl probe, forbidden roots
``{agent_framework, mcp}`` ONLY notify legitimately imports ``urllib`` for the webhook seam).
"""
from __future__ import annotations
import ast
from pathlib import Path
_SRC_DIR = Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser"
_MAF_ROOTS = {"agent_framework", "mcp"}
def test_notify_registered_maf_free() -> None:
"""Meta: notify.py is registered in the MAF-free guard list, so ``test_okf_is_maf_free``
actually scans it otherwise the MAF-free claim would be green-but-dead (never checked)."""
from tests.test_okf import _MAF_FREE_MODULES
assert "notify.py" in _MAF_FREE_MODULES
def _runtime_import_nodes(tree: ast.Module) -> list[ast.Import | ast.ImportFrom]:
"""Every ``Import``/``ImportFrom`` node OUTSIDE an ``if TYPE_CHECKING:`` body. notify's
``Verdict`` import is type-only (TYPE_CHECKING) and must NOT count as a runtime edge
the probe walks the runtime graph, exactly what D7 portability is about."""
skip: set[int] = set()
for node in ast.walk(tree):
if isinstance(node, ast.If):
test = node.test
if (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or (
isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
):
for child in node.body:
for sub in ast.walk(child):
skip.add(id(sub))
return [
node
for node in ast.walk(tree)
if isinstance(node, (ast.Import, ast.ImportFrom)) and id(node) not in skip
]
def _first_party_submodule_edges(tree: ast.Module) -> set[str]:
"""``portfolio_optimiser.<name>`` RUNTIME submodule import edges — modeled on
``test_hitl_loadbearing._first_party_submodule_edges`` (same package-top/``__init__``
discount), restricted to non-TYPE_CHECKING nodes via ``_runtime_import_nodes``."""
edges: set[str] = set()
for node in _runtime_import_nodes(tree):
if isinstance(node, ast.ImportFrom):
parts = (node.module or "").split(".")
if len(parts) >= 2 and parts[0] == "portfolio_optimiser" and parts[1] != "__init__":
edges.add(parts[1])
else:
for alias in node.names:
parts = alias.name.split(".")
if len(parts) >= 2 and parts[0] == "portfolio_optimiser" and parts[1] != "__init__":
edges.add(parts[1])
return edges
def _imports_maf(tree: ast.Module) -> bool:
for node in _runtime_import_nodes(tree):
if isinstance(node, ast.Import):
if any(a.name.split(".")[0] in _MAF_ROOTS for a in node.names):
return True
elif (node.module or "").split(".")[0] in _MAF_ROOTS:
return True
return False
def test_notify_transitive_import_graph_is_maf_free() -> None:
"""LOAD-BEARING: no module reachable from ``notify.py``'s OWN first-party RUNTIME import edges
imports ``agent_framework``/``mcp``. Static AST BFS (never traversing ``__init__.py``;
TYPE_CHECKING edges discounted the ``Verdict`` annotation is type-only by design). Detach
point (documented): promote the ``verdicts`` import to runtime (e.g. ``from
portfolio_optimiser.verdicts import verdict_to_dict``) the walk reaches ``verdicts.py``
(``agent_framework`` at :29) RED. A clean notify GREEN."""
seen: set[str] = set()
queue = list(
_first_party_submodule_edges(ast.parse((_SRC_DIR / "notify.py").read_text("utf-8")))
)
maf_bearing: list[str] = []
while queue:
mod = queue.pop()
if mod in seen:
continue
seen.add(mod)
mod_file = _SRC_DIR / f"{mod}.py"
if not mod_file.is_file():
continue
tree = ast.parse(mod_file.read_text("utf-8"))
if _imports_maf(tree):
maf_bearing.append(mod)
queue.extend(_first_party_submodule_edges(tree) - seen)
assert maf_bearing == [], (
f"notify's transitive first-party import graph reaches MAF-bearing module(s): "
f"{sorted(maf_bearing)}"
)

View file

@ -18,7 +18,7 @@ from portfolio_optimiser import okf
# Framework-neutral, D7-portable modules that must never import MAF/mcp (C2:
# the guard previously scanned only okf.py; dimension.py is now covered too).
_MAF_FREE_MODULES = ["okf.py", "dimension.py", "outbox.py", "costsim.py", "hitl.py"]
_MAF_FREE_MODULES = ["okf.py", "dimension.py", "outbox.py", "costsim.py", "hitl.py", "notify.py"]
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"