portfolio-optimiser-claude/src/portfolio_optimiser_claude/notify.py
Kjell Tore Guttormsen a2acfc0f98 feat(portfolio): K10 — notification/notifier seam, opt-in webhook egress (parity row 23) [skip-docs]
S5.2-analog. New notify.py: Notifier protocol + console/file/webhook sinks. The
webhook (the one transport that leaves the machine) fires ONLY behind an explicit
per-run opt-in flag (--allow-webhook-egress), mirroring ingest-spec §8 (the flag
is a run argument, never a config field). Transport is injected — canned in the
suite (NULL socket), real transport behind one seam function default_webhook_transport;
an AST grep-guard proves no network path exists outside that seam. run.py (both
outcomes — a budget stop notifies too) and hitl.py (read-only preserved) share the
same opt-in-gated CLI seam, refusing a webhook-without-opt-in before any spend.
Payload shape is stack-local (no shared notification spec; divergence documented).

Two new load-bearing test files (18 tests): opt-in gate + payload structure + the
grep-guard + run/hitl emit wiring + run-level opt-in threading, each detach-proven
RED. 544 -> 562 green, full gate clean (ruff+format+mypy strict, 26 src files).
README sync (test count x2 + notify.py module note + load-bearing omtale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
2026-07-24 20:16:56 +02:00

229 lines
8.6 KiB
Python

"""Notification delivery (method-spec §5; S5.2-analog; paritetsrad 23; K10).
Deliverable notification implementations that never break the no-silent-egress
invariant (§8). A run or a HITL pass can DELIVER a small structured event to one
or more sinks:
* ``ConsoleNotifier`` — prints a one-line summary (local, no socket).
* ``FileNotifier`` — writes the event as deterministic house JSON (local).
* ``WebhookNotifier`` — POSTs the event to a URL. This is the ONE transport that
leaves the machine, so it is gated behind an EXPLICIT per-run opt-in flag,
mirroring ingest-spec §8: *the flag is a run argument, never a config field —
the config cannot grant itself network access.* Without the flag the webhook
refuses fail-fast (``EgressNotPermitted``) before any transport exists.
The webhook transport is INJECTED (``Transport`` = ``(url, payload) -> None``).
The suite passes a canned transport, so no socket is ever opened; the real
transport lives behind the ONE seam function ``default_webhook_transport`` (the
only network path in this module — a grep-guard in
``tests/test_notify_loadbearing.py`` proves nothing outside it touches the
network). This mirrors the run entrance's client seam: ``run.py`` constructs the
real SDK client only on the CLI path, never in the suite.
Payload shape is STACK-LOCAL. There is no shared notification spec across the
MAF/SDK siblings; the ``event`` / ``summary`` / ``fields`` shape here is this
stack's own, and the divergence is accepted and documented (pinned by the
payload-structure test). ``fields`` must be JSON-serializable.
Run wiring: ``add_notify_args``/``notify_config_from_args`` give ``run.py`` and
``hitl.py`` the same opt-in-gated CLI seam; ``build_notifiers`` fails fast on a
webhook without opt-in; ``emit`` fans one event out to every configured sink.
"""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Mapping, Protocol, Sequence, runtime_checkable
from portfolio_optimiser_claude.artifacts import _dump_json
# The injectable webhook seam: (url, payload bytes) -> None. The suite injects a
# canned callable; the run path injects ``default_webhook_transport``.
Transport = Callable[[str, bytes], None]
class EgressNotPermitted(RuntimeError):
"""A webhook was configured WITHOUT the explicit per-run egress opt-in (§8)."""
def __init__(self, url: str) -> None:
super().__init__(
f"webhook egress to {url!r} refused: it requires the explicit per-run "
"opt-in flag (a run argument, never a config field — the config cannot "
"grant itself network access, ingest-spec §8)"
)
self.url = url
@dataclass(frozen=True)
class Notification:
"""One structured event to deliver — the stack-local payload shape (§1).
``event`` is a stable machine key (e.g. ``"run.completed"``); ``summary`` is
one human line; ``fields`` carries the structured, JSON-serializable detail.
"""
event: str
summary: str
fields: Mapping[str, object] = field(default_factory=dict)
def as_payload(self) -> dict[str, object]:
"""The canonical dict — the SAME shape every sink serializes."""
return {"event": self.event, "summary": self.summary, "fields": dict(self.fields)}
def _encode(event: Notification) -> bytes:
"""Deterministic compact JSON bytes (sorted keys) — no run-to-run drift."""
return json.dumps(event.as_payload(), sort_keys=True).encode("utf-8")
@runtime_checkable
class Notifier(Protocol):
"""A delivery sink — one event in, delivered to one destination."""
def notify(self, event: Notification) -> None: ...
class ConsoleNotifier:
"""Print a one-line summary — local, never a socket."""
def notify(self, event: Notification) -> None:
print(f"NOTIFY [{event.event}] {event.summary}")
class FileNotifier:
"""Write the event as deterministic house JSON to ``{out_dir}/{event}.json``."""
def __init__(self, out_dir: Path) -> None:
self._out_dir = out_dir
def notify(self, event: Notification) -> None:
self._out_dir.mkdir(parents=True, exist_ok=True)
_dump_json(self._out_dir / f"{event.event}.json", event.as_payload())
class WebhookNotifier:
"""POST the event to ``url`` via the injected ``transport`` — egress, opt-in only.
The opt-in gate lives HERE, at construction (fail-fast, §8): an un-opted-in
webhook never comes into existence, so its transport can never fire. The only
construction path is ``build_notifiers``, which threads the run-argument flag
into ``egress_opt_in``.
"""
def __init__(self, url: str, transport: Transport, *, egress_opt_in: bool) -> None:
if not egress_opt_in:
raise EgressNotPermitted(url)
self._url = url
self._transport = transport
def notify(self, event: Notification) -> None:
self._transport(self._url, _encode(event))
def default_webhook_transport() -> Transport:
"""THE injectable network seam — the ONLY socket path in this module.
``urllib`` is imported LAZILY here (like ``run.py``'s ``default_client_factory``
imports the SDK client) so importing ``notify.py`` stays network-free and the
grep-guard can assert every network import lives INSIDE this one function.
Never called by the suite — tests inject a canned transport, so no socket is
opened. Returning the closure opens nothing; only invoking it (a real,
opted-in run) reaches the network.
"""
import urllib.request
def _send(url: str, payload: bytes) -> None:
request = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request): # noqa: S310 — opt-in egress, URL is operator-set
pass
return _send
@dataclass(frozen=True)
class NotifyConfig:
"""The notify seam's run arguments — the webhook opt-in is one of them (§8).
``webhook_egress_opt_in`` is a RUN ARGUMENT (the CLI flag), never a config
field: a config cannot grant itself egress. A ``webhook_url`` without the flag
is refused by ``build_notifiers`` before any transport exists.
"""
console: bool = False
file_dir: Path | None = None
webhook_url: str | None = None
webhook_egress_opt_in: bool = False
def build_notifiers(
cfg: NotifyConfig, *, webhook_transport: Transport | None = None
) -> list[Notifier]:
"""Build the configured sinks; fail-fast on a webhook without the opt-in (§8).
The transport is injected: the suite passes a canned transport (no socket);
the run path leaves it ``None`` so ``default_webhook_transport`` is used. An
empty config builds nothing.
"""
notifiers: list[Notifier] = []
if cfg.console:
notifiers.append(ConsoleNotifier())
if cfg.file_dir is not None:
notifiers.append(FileNotifier(cfg.file_dir))
if cfg.webhook_url is not None:
transport = (
webhook_transport if webhook_transport is not None else default_webhook_transport()
)
notifiers.append(
WebhookNotifier(cfg.webhook_url, transport, egress_opt_in=cfg.webhook_egress_opt_in)
)
return notifiers
def emit(notifiers: Sequence[Notifier], event: Notification) -> None:
"""Fan one event out to every configured sink (no-op when there are none)."""
for notifier in notifiers:
notifier.notify(event)
def add_notify_args(parser: argparse.ArgumentParser) -> None:
"""Add the shared notify CLI seam to a parser (used by run.py and hitl.py)."""
parser.add_argument(
"--notify-console", action="store_true", help="print a one-line notification summary"
)
parser.add_argument(
"--notify-file",
type=Path,
default=None,
metavar="DIR",
help="write the notification as deterministic JSON to DIR",
)
parser.add_argument(
"--notify-webhook",
type=str,
default=None,
metavar="URL",
help="POST the notification to URL (egress — requires --allow-webhook-egress)",
)
parser.add_argument(
"--allow-webhook-egress",
action="store_true",
help="explicit per-run opt-in for webhook egress (§8; a run argument, never config)",
)
def notify_config_from_args(args: argparse.Namespace) -> NotifyConfig:
"""Map the shared notify CLI flags into a ``NotifyConfig``."""
return NotifyConfig(
console=bool(args.notify_console),
file_dir=args.notify_file,
webhook_url=args.notify_webhook,
webhook_egress_opt_in=bool(args.allow_webhook_egress),
)