"""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), )