From 7d087d5e55ac886ba88a050ee5fc7d8a78c6f86c Mon Sep 17 00:00:00 2001 From: Manish Kapoor Date: Mon, 17 Aug 2026 12:54:40 +0530 Subject: [PATCH] fix: source_events_per_minute=0 must block all events, not disable the limit Bug: In AutonomyGovernor.admit_event(), the guard: if self.source_events_per_minute > 0: skips the rate limit entirely when the value is 0. An operator setting source_events_per_minute=0 intends 'block all events from any source', but actually gets 'allow unlimited events' -- the control does not hold. This is exactly the kind of bug the session warns about: a ceiling that resets to infinity at the boundary value. Fix: Change the guard to >= 0 and handle the zero case explicitly by refusing immediately with a clear reason. Test: test_zero_source_rate_limit_blocks_all_events fails before this change (verdict.admitted is True) and passes after (admitted is False). --- s16code/events/governor.py | 10 ++++- tests/test_zero_source_rate_limit.py | 65 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/test_zero_source_rate_limit.py diff --git a/s16code/events/governor.py b/s16code/events/governor.py index 16f5e92..1932768 100644 --- a/s16code/events/governor.py +++ b/s16code/events/governor.py @@ -84,7 +84,15 @@ def admit_event(self, event: EventEnvelope, *, now: datetime | None = None) -> V return Verdict(False, f"event was caused by this agent ({actor}); refusing to answer itself", "self_trigger", {"actor": actor}) - if self.source_events_per_minute > 0: + if self.source_events_per_minute is not None and self.source_events_per_minute >= 0: + if self.source_events_per_minute == 0: + return Verdict( + False, + f"source {event.source!r} blocked: source_events_per_minute is 0", + "source_rate_limit", + {"source": event.source, "observed": 0, + "limit": 0}, + ) recent = self.store.count_recent_events(event.source, since=moment - timedelta(minutes=1)) if recent >= self.source_events_per_minute: return Verdict( diff --git a/tests/test_zero_source_rate_limit.py b/tests/test_zero_source_rate_limit.py new file mode 100644 index 0000000..6772e07 --- /dev/null +++ b/tests/test_zero_source_rate_limit.py @@ -0,0 +1,65 @@ +"""Test: source_events_per_minute=0 must block all events, not disable the limit. + +Bug: In AutonomyGovernor.admit_event(), the check: + if self.source_events_per_minute > 0: +skips the rate limit entirely when the value is 0. An operator setting +source_events_per_minute=0 intends "block all events from this source", +but actually gets "allow unlimited events" — the control does not hold. + +Fix: Change the guard to >= 0 and handle the zero case explicitly. +""" + +from datetime import UTC, datetime + +from s16code.events import AutonomyGovernor, EventEnvelope, EventStore + + +def _event(**changes) -> EventEnvelope: + body = {"id": "e1", "source": "webhooks", "type": "webhook.received", + "occurred_at": datetime.now(UTC), "data": {}} + body.update(changes) + return EventEnvelope(**body) + + +def test_zero_source_rate_limit_blocks_all_events(tmp_path) -> None: + """source_events_per_minute=0 must refuse every event, not skip the check.""" + store = EventStore(tmp_path / "state") + governor = AutonomyGovernor(store, source_events_per_minute=0) + + event = _event() + verdict = governor.admit_event(event) + + # Before the fix: verdict.admitted is True (rate limit skipped) + # After the fix: verdict.admitted is False (all events blocked) + assert verdict.admitted is False, ( + f"Expected event to be refused when source_events_per_minute=0, " + f"but it was admitted. The rate limit was bypassed!" + ) + assert verdict.control == "source_rate_limit" + assert "0" in verdict.reason + + +def test_positive_source_rate_limit_still_works(tmp_path) -> None: + """A positive limit must still admit events below the threshold.""" + store = EventStore(tmp_path / "state") + governor = AutonomyGovernor(store, source_events_per_minute=5) + + event = _event() + verdict = governor.admit_event(event) + + assert verdict.admitted is True + + +def test_source_rate_limit_refuses_after_threshold(tmp_path) -> None: + """Events beyond the limit must be refused.""" + store = EventStore(tmp_path / "state") + governor = AutonomyGovernor(store, source_events_per_minute=2) + + # Ingest 2 events to fill the window + store.ingest(_event(id="e1")) + store.ingest(_event(id="e2")) + + # Third event should be refused + verdict = governor.admit_event(_event(id="e3")) + assert verdict.admitted is False + assert verdict.control == "source_rate_limit"