feat(s52): fail-fast NotifierConfig + build_notifier factory (egress gate threaded)
This commit is contained in:
parent
fde44ceeb6
commit
1267f6c3eb
2 changed files with 64 additions and 1 deletions
|
|
@ -6,7 +6,7 @@ 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
|
``__call__``-shaped structural superset of the seam's ``Callable``, so any plain callable still
|
||||||
satisfies it.
|
satisfies it.
|
||||||
|
|
||||||
**MAF-free** (D7-portable): pure stdlib. ``Verdict`` is imported ONLY under ``TYPE_CHECKING`` and
|
**MAF-free** (D7-portable): stdlib + pydantic. ``Verdict`` is imported ONLY under ``TYPE_CHECKING`` and
|
||||||
every runtime access is duck-typed — so importing this module never pulls in ``agent_framework``.
|
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
|
Registered in ``tests/test_okf.py``'s ``_MAF_FREE_MODULES`` (direct-import guard
|
||||||
``test_okf_is_maf_free``) and probed transitively by
|
``test_okf_is_maf_free``) and probed transitively by
|
||||||
|
|
@ -23,6 +23,8 @@ from typing import TYPE_CHECKING, Any, Protocol, TextIO, runtime_checkable
|
||||||
from urllib.error import URLError
|
from urllib.error import URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from pydantic import BaseModel, model_validator
|
||||||
|
|
||||||
if TYPE_CHECKING: # verdicts imports agent_framework — keep it out of the runtime import graph
|
if TYPE_CHECKING: # verdicts imports agent_framework — keep it out of the runtime import graph
|
||||||
from portfolio_optimiser.verdicts import Verdict
|
from portfolio_optimiser.verdicts import Verdict
|
||||||
|
|
||||||
|
|
@ -128,3 +130,40 @@ class WebhookNotifier:
|
||||||
# STRICTER than ingest.py:314: the message NEVER carries the url — a Slack/Teams
|
# STRICTER than ingest.py:314: the message NEVER carries the url — a Slack/Teams
|
||||||
# webhook URL embeds the receiver secret (the original cause stays chained).
|
# webhook URL embeds the receiver secret (the original cause stays chained).
|
||||||
raise NotifyError("webhook POST failed") from exc
|
raise NotifyError("webhook POST failed") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# --- config + factory (fail-fast, egress never config-grantable) ----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class NotifierConfig(BaseModel):
|
||||||
|
"""Declarative notifier choice. ``type`` membership is enforced by ``build_notifier`` (unknown
|
||||||
|
type → ``ValueError``); the validator pins the per-type required fields. Deliberately NO
|
||||||
|
``allow_egress`` field — egress opt-in is a code-level factory kwarg, so config alone can
|
||||||
|
never grant it (mirrors ingest's ``allow_network`` discipline)."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
url: str | None = None
|
||||||
|
path: str | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _required_fields_by_type(self) -> NotifierConfig:
|
||||||
|
if self.type == "webhook" and not self.url:
|
||||||
|
raise ValueError("webhook notifier config requires a non-empty url")
|
||||||
|
if self.type == "file" and not self.path:
|
||||||
|
raise ValueError("file notifier config requires a non-empty path")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def build_notifier(config: NotifierConfig, *, allow_egress: bool = False) -> Notifier:
|
||||||
|
"""Type-dispatch factory. The webhook branch threads ``allow_egress`` into
|
||||||
|
``WebhookNotifier`` — with the default ``False`` the Step-3 gate fires (``NotifyRefused``),
|
||||||
|
so a config file alone still cannot enable egress."""
|
||||||
|
if config.type == "console":
|
||||||
|
return ConsoleNotifier()
|
||||||
|
if config.type == "file":
|
||||||
|
assert config.path is not None # guaranteed by _required_fields_by_type
|
||||||
|
return FileNotifier(config.path)
|
||||||
|
if config.type == "webhook":
|
||||||
|
assert config.url is not None # guaranteed by _required_fields_by_type
|
||||||
|
return WebhookNotifier(config.url, allow_egress=allow_egress)
|
||||||
|
raise ValueError(f"unknown notifier type: {config.type!r}")
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,19 @@ import inspect
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from portfolio_optimiser.notify import (
|
from portfolio_optimiser.notify import (
|
||||||
ConsoleNotifier,
|
ConsoleNotifier,
|
||||||
FileNotifier,
|
FileNotifier,
|
||||||
Notifier,
|
Notifier,
|
||||||
|
NotifierConfig,
|
||||||
|
NotifyRefused,
|
||||||
WebhookNotifier,
|
WebhookNotifier,
|
||||||
_urllib_post,
|
_urllib_post,
|
||||||
_verdict_payload,
|
_verdict_payload,
|
||||||
|
build_notifier,
|
||||||
)
|
)
|
||||||
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, capture_verdict, verdict_to_dict
|
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, capture_verdict, verdict_to_dict
|
||||||
|
|
||||||
|
|
@ -105,3 +111,21 @@ def test_webhook_default_post_is_urllib_post() -> None:
|
||||||
the single socket path and is never called by the offline suite."""
|
the single socket path and is never called by the offline suite."""
|
||||||
sig = inspect.signature(WebhookNotifier.__init__)
|
sig = inspect.signature(WebhookNotifier.__init__)
|
||||||
assert sig.parameters["post"].default is _urllib_post
|
assert sig.parameters["post"].default is _urllib_post
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_fail_fast() -> None:
|
||||||
|
"""Config is fail-fast (mirrors ``GoalContract``): a webhook config without a url refuses at
|
||||||
|
validation; an unknown type refuses at the factory; and the factory alone canNOT grant egress —
|
||||||
|
``allow_egress`` is a code kwarg (default False → ``NotifyRefused``), never a config field."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
NotifierConfig(type="webhook") # url missing → model_validator raises
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="unknown notifier type"):
|
||||||
|
build_notifier(NotifierConfig(type="smoke-signal"))
|
||||||
|
|
||||||
|
webhook_cfg = NotifierConfig(type="webhook", url="https://hooks.example.test/T0/B0/tok")
|
||||||
|
with pytest.raises(NotifyRefused):
|
||||||
|
build_notifier(webhook_cfg) # allow_egress defaults False → fail-closed
|
||||||
|
|
||||||
|
notifier = build_notifier(webhook_cfg, allow_egress=True)
|
||||||
|
assert isinstance(notifier, WebhookNotifier)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue