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:
Kjell Tore Guttormsen 2026-07-24 20:16:56 +02:00
commit a2acfc0f98
6 changed files with 863 additions and 11 deletions

View file

@ -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