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
This commit is contained in:
parent
b9dd479865
commit
a2acfc0f98
6 changed files with 863 additions and 11 deletions
|
|
@ -41,6 +41,16 @@ from pydantic import BaseModel, Field, ValidationError, model_validator
|
|||
|
||||
from portfolio_optimiser_claude.inbox import load_inbox
|
||||
from portfolio_optimiser_claude.ir import SavingsProposal
|
||||
from portfolio_optimiser_claude.notify import (
|
||||
EgressNotPermitted,
|
||||
Notification,
|
||||
Notifier,
|
||||
Transport,
|
||||
add_notify_args,
|
||||
build_notifiers,
|
||||
emit,
|
||||
notify_config_from_args,
|
||||
)
|
||||
from portfolio_optimiser_claude.okf import navigate_bundle
|
||||
|
||||
_OUTCOME_SUFFIX = "-outcome.json"
|
||||
|
|
@ -199,7 +209,7 @@ def _bundle_dirs(values: list[str] | None) -> list[Path]:
|
|||
return [Path(value) for value in (values or [])]
|
||||
|
||||
|
||||
def _cmd_pending(args: argparse.Namespace) -> int:
|
||||
def _cmd_pending(args: argparse.Namespace, notifiers: Sequence[Notifier]) -> int:
|
||||
pending = pending_proposals(
|
||||
Path(args.outbox), Path(args.inbox), bundle_dirs=_bundle_dirs(args.bundle)
|
||||
)
|
||||
|
|
@ -209,10 +219,21 @@ def _cmd_pending(args: argparse.Namespace) -> int:
|
|||
f" {proposal.run_id} verdict_id={proposal.verdict_id} "
|
||||
f"measure={proposal.measure!r} project={proposal.project_id}"
|
||||
)
|
||||
# K10: notification is egress, NOT writing any of the three read layers —
|
||||
# the read-only invariant (§3 Step 7) holds (proven by the seam test's
|
||||
# before/after byte snapshot of outbox+inbox).
|
||||
emit(
|
||||
notifiers,
|
||||
Notification(
|
||||
event="hitl.pending",
|
||||
summary=f"{len(pending)} proposal(s) awaiting a verdict",
|
||||
fields={"pending": len(pending)},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_route(args: argparse.Namespace) -> int:
|
||||
def _cmd_route(args: argparse.Namespace, notifiers: Sequence[Notifier]) -> int:
|
||||
try:
|
||||
routing = load_routing(json.loads(Path(args.routing).read_text("utf-8")))
|
||||
except (OSError, ValueError, ValidationError) as exc:
|
||||
|
|
@ -229,15 +250,29 @@ def _cmd_route(args: argparse.Namespace) -> int:
|
|||
f" {item.proposal.run_id} measure={item.proposal.measure!r} "
|
||||
f"verdict_id={item.proposal.verdict_id} -> {expert}"
|
||||
)
|
||||
unrouted = sum(1 for item in routed if item.expert is None)
|
||||
emit(
|
||||
notifiers,
|
||||
Notification(
|
||||
event="hitl.route",
|
||||
summary=f"{len(routed)} pending proposal(s) routed, {unrouted} UNROUTED",
|
||||
fields={"pending": len(routed), "unrouted": unrouted},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
def main(argv: list[str] | None = None, *, notifier_transport: Transport | None = None) -> int:
|
||||
"""The thin CLI: ``pending`` lists outstanding proposals; ``route`` adds experts.
|
||||
|
||||
``pending`` is a pure report (exit 0). ``route`` loads the routing config
|
||||
fail-fast — a malformed/missing config exits non-zero WITHOUT touching any
|
||||
layer (§10). Neither subcommand writes anything.
|
||||
layer (§10). Neither subcommand writes any of the three read layers.
|
||||
|
||||
An optional notify seam (K10) delivers a summary event; a ``--notify-webhook``
|
||||
without ``--allow-webhook-egress`` is refused fail-fast (§8) BEFORE any read.
|
||||
``notifier_transport`` is the injected webhook transport (canned in the suite,
|
||||
``default_webhook_transport`` on the CLI path — no socket in tests).
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
|
|
@ -256,6 +291,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
action="append",
|
||||
help="bundle dir whose promoted verdicts also settle a proposal (repeatable)",
|
||||
)
|
||||
add_notify_args(sub)
|
||||
|
||||
pending_parser = subparsers.add_parser("pending", help="list proposals awaiting a verdict")
|
||||
_add_common(pending_parser)
|
||||
|
|
@ -269,7 +305,16 @@ def main(argv: list[str] | None = None) -> int:
|
|||
route_parser.set_defaults(func=_cmd_route)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
result: int = args.func(args)
|
||||
# K10 (§8): build the notify sinks BEFORE any read — a --notify-webhook
|
||||
# without --allow-webhook-egress is refused fail-fast here (the opt-in is a
|
||||
# run argument, never a config field).
|
||||
try:
|
||||
notifiers = build_notifiers(
|
||||
notify_config_from_args(args), webhook_transport=notifier_transport
|
||||
)
|
||||
except EgressNotPermitted as exc:
|
||||
parser.error(str(exc))
|
||||
result: int = args.func(args, notifiers)
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
229
src/portfolio_optimiser_claude/notify.py
Normal file
229
src/portfolio_optimiser_claude/notify.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""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),
|
||||
)
|
||||
|
|
@ -24,7 +24,7 @@ from __future__ import annotations
|
|||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
from portfolio_optimiser_claude.artifacts import (
|
||||
_dump_json,
|
||||
|
|
@ -47,6 +47,16 @@ from portfolio_optimiser_claude.ir import SavingsProposal, load_validator_input
|
|||
from portfolio_optimiser_claude.okf import bundle_context, navigate_bundle
|
||||
from portfolio_optimiser_claude.provenance import Citation, Provenance
|
||||
from portfolio_optimiser_claude.loop import ModelClient, run_project
|
||||
from portfolio_optimiser_claude.notify import (
|
||||
EgressNotPermitted,
|
||||
Notification,
|
||||
Notifier,
|
||||
Transport,
|
||||
add_notify_args,
|
||||
build_notifiers,
|
||||
emit,
|
||||
notify_config_from_args,
|
||||
)
|
||||
from portfolio_optimiser_claude.validator import Rejection
|
||||
|
||||
_PROPOSER_ROLE = "proposer"
|
||||
|
|
@ -135,6 +145,7 @@ def execute_run(
|
|||
max_attempts: int,
|
||||
outbox_dir: Path | None = None,
|
||||
run_id: str | None = None,
|
||||
notifiers: Sequence[Notifier] = (),
|
||||
) -> int:
|
||||
"""Drive the loop under the §8 meter; persist artifacts on BOTH outcomes.
|
||||
|
||||
|
|
@ -146,7 +157,12 @@ def execute_run(
|
|||
proposal/outcome pair to the outbox (S2.1) — the system's own output layer,
|
||||
read by K8 (live capture) and K9 (pending tracking). A budget stop has no
|
||||
proposal, so it writes no outbox pair.
|
||||
|
||||
``notifiers`` (K10) receive a structured event on BOTH outcomes — a budget
|
||||
stop is a run outcome, not an absence of one, so it notifies too. The list
|
||||
is empty unless the operator configured a sink; delivery never gates the run.
|
||||
"""
|
||||
run_label = run_id or out_dir.name
|
||||
meter = BudgetMeter(contracts.termination)
|
||||
try:
|
||||
result = run_project(
|
||||
|
|
@ -169,6 +185,14 @@ def execute_run(
|
|||
)
|
||||
for name, path in sorted(stop_paths.items()):
|
||||
print(f"artifact: {name} -> {path}")
|
||||
emit(
|
||||
notifiers,
|
||||
Notification(
|
||||
event="run.stopped",
|
||||
summary=f"run {run_label} stopped by budget: {stop.kind}",
|
||||
fields={"kind": stop.kind, "observed": stop.observed, "limit": stop.limit},
|
||||
),
|
||||
)
|
||||
return 3
|
||||
|
||||
provenance = Provenance(
|
||||
|
|
@ -200,6 +224,19 @@ def execute_run(
|
|||
)
|
||||
for name, path in sorted(outbox_paths.items()):
|
||||
print(f"outbox: {name} -> {path}")
|
||||
emit(
|
||||
notifiers,
|
||||
Notification(
|
||||
event="run.completed",
|
||||
summary=f"run {run_label}: {outcome_kind}",
|
||||
fields={
|
||||
"validator_decision": result.validator_decision,
|
||||
"checker_decision": result.checker_decision,
|
||||
"attempts": result.attempts,
|
||||
"outcome": outcome_kind,
|
||||
},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -294,8 +331,18 @@ def execute_dry_run(
|
|||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None = None) -> int:
|
||||
"""The thin CLI: contracts fail-fast (§10) → compose (§5) → execute (§3, §8)."""
|
||||
def main(
|
||||
argv: list[str] | None = None,
|
||||
*,
|
||||
client_factory: ClientFactory | None = None,
|
||||
notifier_transport: Transport | None = None,
|
||||
) -> int:
|
||||
"""The thin CLI: contracts fail-fast (§10) → compose (§5) → execute (§3, §8).
|
||||
|
||||
``notifier_transport`` is the injected webhook seam (K10): ``None`` uses the
|
||||
real ``default_webhook_transport`` on the CLI path; the suite injects a
|
||||
canned transport so no socket is opened.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run one project through the loop (merge inbox → seed → fold → run)."
|
||||
)
|
||||
|
|
@ -316,6 +363,7 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
|
|||
help="build all, capture run-config + preflight to the outbox, STOP before "
|
||||
"the first model call (K8 live-run drill; no spend, no model call).",
|
||||
)
|
||||
add_notify_args(parser)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# fail-fast (§10 spirit): a run persisted to the outbox MUST carry an explicit
|
||||
|
|
@ -331,6 +379,17 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
|
|||
elif args.outbox is not None and not (args.run_id or "").strip():
|
||||
parser.error("--outbox requires --run-id (no wall-clock default)")
|
||||
|
||||
# K10 (§8): build the notify sinks BEFORE any spend — a --notify-webhook
|
||||
# without --allow-webhook-egress is refused fail-fast here, so no run rides
|
||||
# on a misconfigured egress. The opt-in is a run argument, never a config
|
||||
# field (the config cannot grant itself network access).
|
||||
try:
|
||||
notifiers = build_notifiers(
|
||||
notify_config_from_args(args), webhook_transport=notifier_transport
|
||||
)
|
||||
except EgressNotPermitted as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
# §10: ALL startup contracts schema-validated BEFORE any model client exists.
|
||||
contracts = load_contracts(
|
||||
data_source={"docs_dir": str(args.bundle), "top_k": args.top_k},
|
||||
|
|
@ -392,6 +451,7 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
|
|||
max_attempts=args.max_attempts,
|
||||
outbox_dir=args.outbox,
|
||||
run_id=args.run_id,
|
||||
notifiers=notifiers,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue