proactive-gate 0.8.2__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/.gitignore +3 -0
  2. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/PKG-INFO +4 -7
  3. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/README.md +1 -4
  4. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/pyproject.toml +4 -2
  5. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/__init__.py +1 -1
  6. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/checks.py +62 -2
  7. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/clock.py +32 -1
  8. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/conformance.py +2 -0
  9. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/gate.py +21 -2
  10. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/types.py +7 -0
  11. proactive_gate-0.9.0/tests/test_next_allowed_at.py +175 -0
  12. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/explain.py +0 -0
  13. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/policy.py +0 -0
  14. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/presets.py +0 -0
  15. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/py.typed +0 -0
  16. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/src/proactive_gate/stores.py +0 -0
  17. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/conftest.py +0 -0
  18. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_clock.py +0 -0
  19. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_conformance.py +0 -0
  20. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_explain.py +0 -0
  21. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_redis.py +0 -0
  22. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_requirements_ci.py +0 -0
  23. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_stores.py +0 -0
  24. {proactive_gate-0.8.2 → proactive_gate-0.9.0}/tests/test_version.py +0 -0
@@ -12,3 +12,6 @@ __pycache__/
12
12
 
13
13
  # uv writes this when the Python package is run locally; the library pins nothing.
14
14
  python/uv.lock
15
+
16
+ # Other tools downloaded by npm run compare:fetch: read and run, never committed.
17
+ _upstream/
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: proactive-gate
3
- Version: 0.8.2
4
- Summary: Decide whether a proactive assistant may speak now: ordered checks, budgets, quiet hours, JSON policies, shared conformance fixtures with the TypeScript package.
3
+ Version: 0.9.0
4
+ Summary: proactive-gate is a library that decides whether an AI agent may message a user right now (quiet hours, daily limit, consent, snooze) and records the reason when it may not.
5
5
  Project-URL: Homepage, https://bubblegunn.github.io/proactive-gate/
6
6
  Project-URL: Documentation, https://bubblegunn.github.io/proactive-gate/start/
7
7
  Project-URL: Playground, https://bubblegunn.github.io/proactive-gate/playground/
@@ -11,7 +11,7 @@ Project-URL: Specification, https://github.com/Bubblegunn/proactive-gate/tree/ma
11
11
  Project-URL: Changelog, https://github.com/Bubblegunn/proactive-gate/blob/main/CHANGELOG.md
12
12
  Author: Efe Genc
13
13
  License-Expression: MIT
14
- Keywords: agents,ai-agents,consent,guardrails,interruption,llm,notification-budget,notification-policy,notifications,policy,policy-engine,proactive,proactive-agents,proactive-ai,quiet-hours,rate-limit
14
+ Keywords: agent-notification-policy,agents,ai-agents,consent,do-not-disturb,frequency-cap,guardrails,interruption,llm,notification-budget,notification-fatigue,notification-policy,notification-throttle,notifications,policy,policy-engine,proactive,proactive-agent,proactive-agents,proactive-ai,quiet-hours,rate-limit,tcpa,timezone
15
15
  Classifier: Development Status :: 4 - Beta
16
16
  Classifier: Framework :: AsyncIO
17
17
  Classifier: Intended Audience :: Developers
@@ -40,10 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  # proactive-gate for Python
42
42
 
43
- Decide whether a proactive assistant may speak to this person right now. Ordered checks
44
- (consent, mode, snooze, mute, intensity, quiet hours, trust ramp, dismissal cooldown,
45
- budgets), JSON policies, platform and regulatory presets, and a trace that says which check
46
- stopped the message and why.
43
+ proactive-gate is a library that decides whether an AI agent may message a user right now (quiet hours, daily limit, consent, snooze) and records the reason when it may not. It has ordered checks (consent, mode, snooze, mute, intensity, quiet hours, trust ramp, dismissal cooldown, budgets), JSON policies, platform and regulatory presets, and a trace that says which check stopped the message and why.
47
44
 
48
45
  This is the Python sibling of the TypeScript package. Both implement the same behaviour
49
46
  contract in [`spec/`](https://github.com/Bubblegunn/proactive-gate/tree/main/spec) and run
@@ -1,9 +1,6 @@
1
1
  # proactive-gate for Python
2
2
 
3
- Decide whether a proactive assistant may speak to this person right now. Ordered checks
4
- (consent, mode, snooze, mute, intensity, quiet hours, trust ramp, dismissal cooldown,
5
- budgets), JSON policies, platform and regulatory presets, and a trace that says which check
6
- stopped the message and why.
3
+ proactive-gate is a library that decides whether an AI agent may message a user right now (quiet hours, daily limit, consent, snooze) and records the reason when it may not. It has ordered checks (consent, mode, snooze, mute, intensity, quiet hours, trust ramp, dismissal cooldown, budgets), JSON policies, platform and regulatory presets, and a trace that says which check stopped the message and why.
7
4
 
8
5
  This is the Python sibling of the TypeScript package. Both implement the same behaviour
9
6
  contract in [`spec/`](https://github.com/Bubblegunn/proactive-gate/tree/main/spec) and run
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proactive-gate"
7
- version = "0.8.2"
8
- description = "Decide whether a proactive assistant may speak now: ordered checks, budgets, quiet hours, JSON policies, shared conformance fixtures with the TypeScript package."
7
+ version = "0.9.0"
8
+ description = "proactive-gate is a library that decides whether an AI agent may message a user right now (quiet hours, daily limit, consent, snooze) and records the reason when it may not."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  license = "MIT"
@@ -14,6 +14,8 @@ keywords = [
14
14
  "proactive", "proactive-ai", "proactive-agents", "ai-agents", "llm", "agents",
15
15
  "notifications", "notification-policy", "notification-budget", "quiet-hours",
16
16
  "consent", "rate-limit", "policy", "policy-engine", "guardrails", "interruption",
17
+ "notification-throttle", "frequency-cap", "notification-fatigue", "proactive-agent",
18
+ "do-not-disturb", "tcpa", "timezone", "agent-notification-policy",
17
19
  ]
18
20
  classifiers = [
19
21
  "Development Status :: 4 - Beta",
@@ -22,7 +22,7 @@ from .types import (
22
22
  UserState,
23
23
  )
24
24
 
25
- __version__ = "0.8.2"
25
+ __version__ = "0.9.0"
26
26
 
27
27
  __all__ = [
28
28
  "EN", "PRIORITY_RANK", "KNOWN_CHECKS", "AsyncGate", "AsyncMemoryStore", "AsyncSqliteStore", "AsyncStore", "Candidate", "Check",
@@ -7,10 +7,10 @@ from __future__ import annotations
7
7
  import json
8
8
  import math
9
9
  from collections.abc import Callable, Mapping, Sequence
10
- from datetime import datetime, timedelta
10
+ from datetime import UTC, datetime, timedelta
11
11
  from typing import Protocol, runtime_checkable
12
12
 
13
- from .clock import DAY_SECONDS, day_before, in_window, iso_week_key, local_clock, local_day, parse_hhmm, weekday_of
13
+ from .clock import DAY_SECONDS, day_before, in_window, iso_week_key, local_clock, local_day, next_key_change, parse_hhmm, weekday_of
14
14
  from .types import (
15
15
  PASS,
16
16
  ConsumePlan,
@@ -128,6 +128,9 @@ class Snooze(BaseCheck):
128
128
  reason = f"snoozed until {iso_z(until)}"
129
129
  return defer(reason, until) if self.defer else reject(reason)
130
130
 
131
+ def next_allowed_at(self, ctx: Context) -> datetime | None:
132
+ return ctx.user.snoozed_until
133
+
131
134
 
132
135
  class Mute(BaseCheck):
133
136
  id = "mute"
@@ -185,6 +188,37 @@ def quiet_at(quiet: QuietWindow | QuietSchedule, day: str, minutes: int) -> tupl
185
188
  return None
186
189
 
187
190
 
191
+ _MINUTE = timedelta(minutes=1)
192
+
193
+
194
+ def quiet_ends_at(quiet: QuietWindow | QuietSchedule, tz: str, now: datetime) -> datetime | None:
195
+ """The earliest minute after ``now`` at which quiet hours stop applying, or ``None``.
196
+
197
+ Each jump moves to the end of the window silencing the current local time. A daylight-saving
198
+ change between the two instants lands the jump an hour early (still quiet, so it jumps again)
199
+ or an hour late (so it steps back a minute at a time to the first open minute). Same
200
+ algorithm as ``quietEndsAt`` in the TypeScript package, held to the same fixtures.
201
+ """
202
+ start = now.astimezone(UTC).replace(second=0, microsecond=0)
203
+ at = start
204
+ for _ in range(16):
205
+ minutes, day = local_clock(at, tz)
206
+ hit = quiet_at(quiet, day, minutes)
207
+ if hit is None:
208
+ for _back in range(180):
209
+ earlier = at - _MINUTE
210
+ if earlier <= start:
211
+ break
212
+ before_minutes, before_day = local_clock(earlier, tz)
213
+ if quiet_at(quiet, before_day, before_minutes) is not None:
214
+ break
215
+ at = earlier
216
+ return at if at > now else None
217
+ until_end = (parse_hhmm(hit[0].end) - minutes) % 1440 or 1
218
+ at = at + timedelta(minutes=until_end)
219
+ return None
220
+
221
+
188
222
  class QuietHours(BaseCheck):
189
223
  id = "quietHours"
190
224
 
@@ -209,6 +243,12 @@ class QuietHours(BaseCheck):
209
243
  whose = "" if from_day == day else f" ({weekday_of(from_day)} {from_day})"
210
244
  return reject(f"quiet hours {window.start} to {window.end}{whose} {ctx.user.timezone}; priority {ctx.priority} is below the floor ({self.floor})")
211
245
 
246
+ def next_allowed_at(self, ctx: Context) -> datetime | None:
247
+ qh = ctx.user.quiet_hours
248
+ if qh is None or not ctx.user.timezone:
249
+ return None
250
+ return quiet_ends_at(qh, ctx.user.timezone, ctx.now)
251
+
212
252
 
213
253
  class TrustRamp(BaseCheck):
214
254
  id = "trustRamp"
@@ -321,6 +361,17 @@ class Budget(BaseCheck):
321
361
  return None
322
362
  return ConsumePlan(self.key_for(ctx), self.ttl_seconds, self.limit)
323
363
 
364
+ def key_at(self, user_id: str, at: datetime, tz: str | None) -> str:
365
+ """The counter key in force at ``at``; the reset is the first minute this answers differently."""
366
+ raise NotImplementedError
367
+
368
+ def next_allowed_at(self, ctx: Context) -> datetime | None:
369
+ user = ctx.user
370
+ try:
371
+ return next_key_change(ctx.now, user.timezone, lambda at: self.key_at(user.id, at, user.timezone))
372
+ except NotImplementedError:
373
+ return None
374
+
324
375
 
325
376
  def budget_key(user_id: str, now: datetime, tz: str | None = None) -> str:
326
377
  return f"budget:{user_id}:{local_day(now, tz)}"
@@ -343,6 +394,9 @@ class DailyBudget(Budget):
343
394
  def key_for(self, ctx: Context) -> str:
344
395
  return budget_key(ctx.user.id, ctx.now, ctx.user.timezone)
345
396
 
397
+ def key_at(self, user_id: str, at: datetime, tz: str | None) -> str:
398
+ return budget_key(user_id, at, tz)
399
+
346
400
 
347
401
  class WeeklyBudget(Budget):
348
402
  id = "weeklyBudget"
@@ -353,6 +407,9 @@ class WeeklyBudget(Budget):
353
407
  def key_for(self, ctx: Context) -> str:
354
408
  return weekly_budget_key(ctx.user.id, ctx.now, ctx.user.timezone)
355
409
 
410
+ def key_at(self, user_id: str, at: datetime, tz: str | None) -> str:
411
+ return weekly_budget_key(user_id, at, tz)
412
+
356
413
 
357
414
  class MonthlyBudget(Budget):
358
415
  id = "monthlyBudget"
@@ -363,6 +420,9 @@ class MonthlyBudget(Budget):
363
420
  def key_for(self, ctx: Context) -> str:
364
421
  return monthly_budget_key(ctx.user.id, ctx.now, ctx.user.timezone)
365
422
 
423
+ def key_at(self, user_id: str, at: datetime, tz: str | None) -> str:
424
+ return monthly_budget_key(user_id, at, tz)
425
+
366
426
 
367
427
  class UtilityFloor(BaseCheck):
368
428
  """Act only when the caller's pAccept clears tau = cFA / (cFA + pNeed * cFN)."""
@@ -1,7 +1,8 @@
1
1
  """Local-time arithmetic with the standard library only."""
2
2
  from __future__ import annotations
3
3
 
4
- from datetime import date, datetime, timedelta
4
+ from collections.abc import Callable
5
+ from datetime import UTC, date, datetime, timedelta
5
6
  from zoneinfo import ZoneInfo
6
7
 
7
8
  DAY_SECONDS = 24 * 60 * 60
@@ -54,3 +55,33 @@ def weekday_of(day: str) -> str:
54
55
 
55
56
  def day_before(day: str) -> str:
56
57
  return (date.fromisoformat(day) - timedelta(days=1)).isoformat()
58
+
59
+
60
+ _MINUTE = timedelta(minutes=1)
61
+
62
+
63
+ def _floor_minute(at: datetime) -> datetime:
64
+ return at.astimezone(UTC).replace(second=0, microsecond=0)
65
+
66
+
67
+ def next_key_change(now: datetime, tz: str | None, key_at: Callable[[datetime], str]) -> datetime | None:
68
+ """The first minute after ``now`` at which ``key_at`` answers differently.
69
+
70
+ Found by jumping to successive local midnights, then stepping back a minute at a time to the
71
+ first minute of the new key, which is what a daylight-saving change before midnight needs.
72
+ ``None`` when the key does not change within forty days.
73
+ """
74
+ base = key_at(now)
75
+ start = _floor_minute(now)
76
+ at = start
77
+ for _ in range(40):
78
+ minutes = local_clock(at, tz)[0] if tz else at.hour * 60 + at.minute
79
+ at = at + timedelta(minutes=1440 - minutes)
80
+ if key_at(at) != base:
81
+ for _back in range(180):
82
+ earlier = at - _MINUTE
83
+ if earlier <= start or key_at(earlier) == base:
84
+ break
85
+ at = earlier
86
+ return at
87
+ return None
@@ -53,6 +53,8 @@ def _compare(failures: list[str], at: str, decision: Decision, expect: Mapping[s
53
53
  check("rejectedBy", decision.rejected_by, expect.get("rejectedBy"))
54
54
  check("deferredBy", decision.deferred_by, expect.get("deferredBy"))
55
55
  check("retryAt", iso_z(decision.retry_at) if decision.retry_at else None, expect.get("retryAt"))
56
+ if "nextAllowedAt" in expect:
57
+ check("nextAllowedAt", iso_z(decision.next_allowed_at) if decision.next_allowed_at else None, expect["nextAllowedAt"])
56
58
  if "surfaces" in expect:
57
59
  check("surfaces", list(decision.surfaces), expect["surfaces"])
58
60
  check("deliverAt", iso_z(decision.deliver_at) if decision.deliver_at else None, expect.get("deliverAt"))
@@ -140,10 +140,16 @@ class Evaluation:
140
140
  self._maybe_finish()
141
141
  return
142
142
  if outcome.kind == "reject":
143
- self._finish(rejected_by=check.id, reason=outcome.reason)
143
+ self._finish(rejected_by=check.id, reason=outcome.reason, next_allowed_at=self._hold_for(check, ctx))
144
144
  return
145
145
  if outcome.kind == "defer":
146
- self._finish(deferred_by=check.id, retry_at=outcome.retry_at, reason=outcome.reason)
146
+ retry_at = outcome.retry_at
147
+ self._finish(
148
+ deferred_by=check.id,
149
+ retry_at=retry_at,
150
+ reason=outcome.reason,
151
+ next_allowed_at=retry_at if retry_at is not None and retry_at > self.now else None,
152
+ )
147
153
  return
148
154
  if outcome.kind == "adjust":
149
155
  if outcome.deliver_at is not None:
@@ -152,6 +158,17 @@ class Evaluation:
152
158
  self._surfaces = tuple(outcome.surfaces)
153
159
  self._maybe_finish()
154
160
 
161
+ def _hold_for(self, check: Check, ctx: Context) -> datetime | None:
162
+ """What the denying check says about when it would stop denying; a failure or a past instant is no answer."""
163
+ hook: Callable[[Context], datetime | None] | None = getattr(check, "next_allowed_at", None)
164
+ if hook is None:
165
+ return None
166
+ try:
167
+ at = hook(ctx)
168
+ return at if at is not None and at > self.now else None
169
+ except BaseException: # noqa: BLE001 - an answer that fails is no answer, never a failed decision
170
+ return None
171
+
155
172
  def _maybe_finish(self) -> None:
156
173
  if self._index >= len(self.checks):
157
174
  self._finish(allowed=True)
@@ -163,6 +180,7 @@ class Evaluation:
163
180
  deferred_by: str | None = None,
164
181
  retry_at: datetime | None = None,
165
182
  reason: str | None = None,
183
+ next_allowed_at: datetime | None = None,
166
184
  ) -> None:
167
185
  user, candidate = self.input.user, self.input.candidate
168
186
  self._result = Decision(
@@ -180,6 +198,7 @@ class Evaluation:
180
198
  deferred_by=deferred_by,
181
199
  retry_at=retry_at,
182
200
  reason=reason,
201
+ next_allowed_at=next_allowed_at,
183
202
  )
184
203
  self._call("finally_", self._result)
185
204
 
@@ -228,6 +228,13 @@ class Decision:
228
228
  deferred_by: str | None = None
229
229
  retry_at: datetime | None = None
230
230
  reason: str | None = None
231
+ next_allowed_at: datetime | None = None
232
+ """Earliest instant at which the check that denied this candidate would stop denying it.
233
+
234
+ A lower bound, not a promise: a later check can still deny then. ``None`` when allowed, when
235
+ the denying check cannot say, and when a shadowed check would have denied. For a deferred
236
+ decision it equals ``retry_at``.
237
+ """
231
238
 
232
239
 
233
240
  @dataclass(frozen=True, slots=True)
@@ -0,0 +1,175 @@
1
+ """``Decision.next_allowed_at``: the gate takes it from the check that denied, and only then.
2
+
3
+ The shared fixtures under spec/fixtures/hold hold the instants; these tests cover what a
4
+ fixture cannot say: a check of your own, a hook that fails, and the brute-force comparison.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Callable, Sequence
9
+ from datetime import UTC, datetime, timedelta
10
+
11
+ import pytest
12
+
13
+ from proactive_gate import Candidate, Context, EvaluateInput, Gate, Outcome, UserState
14
+ from proactive_gate.checks import BaseCheck, budget_key, monthly_budget_key, quiet_at, quiet_ends_at, weekly_budget_key
15
+ from proactive_gate.clock import local_clock, next_key_change
16
+ from proactive_gate.types import QuietSchedule, QuietWindow, defer, reject
17
+
18
+ MINUTE = timedelta(minutes=1)
19
+ NOW = datetime(2026, 9, 4, 9, 0, tzinfo=UTC)
20
+ USER = UserState(id="u1", consent=True, timezone="Europe/Istanbul")
21
+ CANDIDATE = Candidate(id="c1", type="reminder")
22
+
23
+
24
+ class Denies(BaseCheck):
25
+ """Rejects every candidate and answers ``answer`` when asked when it would stop."""
26
+
27
+ def __init__(self, id: str, answer: datetime | BaseException | None = None, shadow: bool = False) -> None:
28
+ self.id = id
29
+ self._answer = answer
30
+ self.shadow = shadow
31
+ self.calls = 0
32
+
33
+ def run(self, ctx: Context, values: object) -> Outcome:
34
+ return reject("no")
35
+
36
+ def next_allowed_at(self, ctx: Context) -> datetime | None:
37
+ self.calls += 1
38
+ if isinstance(self._answer, BaseException):
39
+ raise self._answer
40
+ return self._answer
41
+
42
+
43
+ class DeniesWithoutAnAnswer(BaseCheck):
44
+ """Rejects, and has no ``next_allowed_at`` at all, like every check written before 1.5.0."""
45
+
46
+ id = "plain"
47
+
48
+ def run(self, ctx: Context, values: object) -> Outcome:
49
+ return reject("no")
50
+
51
+
52
+ class Passes(BaseCheck):
53
+ id = "passes"
54
+ calls = 0
55
+
56
+ def next_allowed_at(self, ctx: Context) -> datetime | None:
57
+ self.calls += 1
58
+ return NOW + timedelta(days=1)
59
+
60
+
61
+ def evaluate(checks: Sequence[BaseCheck], now: datetime = NOW) -> tuple[bool, str | None, datetime | None]:
62
+ decision = Gate(checks).evaluate(EvaluateInput(USER, CANDIDATE, now))
63
+ return decision.allowed, decision.rejected_by, decision.next_allowed_at
64
+
65
+
66
+ def test_the_answer_comes_from_the_check_that_rejected_and_only_after_it_rejected() -> None:
67
+ first, second, third = Passes(), Denies("second", NOW + timedelta(hours=3)), Denies("third", NOW + timedelta(days=9))
68
+ allowed, by, answer = evaluate([first, second, third])
69
+ assert (allowed, by, answer) == (False, "second", NOW + timedelta(hours=3))
70
+ assert (first.calls, second.calls, third.calls) == (0, 1, 0)
71
+
72
+
73
+ @pytest.mark.parametrize(
74
+ "answer",
75
+ [None, NOW, NOW - timedelta(hours=1), RuntimeError("boom")],
76
+ ids=["none", "now itself", "the past", "a raise"],
77
+ )
78
+ def test_an_answer_that_is_none_not_after_now_or_raises_is_no_answer(answer: datetime | BaseException | None) -> None:
79
+ allowed, by, got = evaluate([Denies("c", answer)])
80
+ assert (allowed, by, got) == (False, "c", None)
81
+
82
+
83
+ def test_allowed_shadowed_and_unanswering_checks_carry_none() -> None:
84
+ assert evaluate([Denies("s", NOW + MINUTE, shadow=True)]) == (True, None, None)
85
+ assert evaluate([DeniesWithoutAnAnswer()]) == (False, "plain", None)
86
+
87
+
88
+ def test_a_naive_datetime_answer_is_no_answer_and_the_decision_is_still_produced() -> None:
89
+ naive = datetime(2026, 9, 4, 12, 0)
90
+ decision = Gate([Denies("c", naive)]).evaluate(EvaluateInput(USER, CANDIDATE, NOW))
91
+ assert (decision.allowed, decision.rejected_by, decision.next_allowed_at) == (False, "c", None)
92
+
93
+
94
+ class Deferring(BaseCheck):
95
+ id = "d"
96
+
97
+ def __init__(self, retry_at: datetime) -> None:
98
+ self.retry_at = retry_at
99
+
100
+ def run(self, ctx: Context, values: object) -> Outcome:
101
+ return defer("later", self.retry_at)
102
+
103
+
104
+ def test_a_deferred_decision_carries_retry_at_as_its_answer() -> None:
105
+ until = NOW + timedelta(hours=1)
106
+ decision = Gate([Deferring(until)]).evaluate(EvaluateInput(USER, CANDIDATE, NOW))
107
+ assert decision.next_allowed_at == until == decision.retry_at
108
+ past = Gate([Deferring(NOW - MINUTE)]).evaluate(EvaluateInput(USER, CANDIDATE, NOW))
109
+ assert past.next_allowed_at is None
110
+
111
+
112
+ def first_open_minute(quiet: QuietWindow | QuietSchedule, zone: str, now: datetime) -> datetime | None:
113
+ at = now.astimezone(UTC).replace(second=0, microsecond=0)
114
+ for _ in range(60 * 48):
115
+ at += MINUTE
116
+ minutes, day = local_clock(at, zone)
117
+ if quiet_at(quiet, day, minutes) is None:
118
+ return at
119
+ return None
120
+
121
+
122
+ def test_quiet_ends_at_equals_a_minute_by_minute_scan_across_daylight_saving_changes() -> None:
123
+ zones = ["America/New_York", "Europe/London", "Australia/Lord_Howe", "Asia/Kathmandu", "America/Havana"]
124
+ windows = [QuietWindow("22:00", "08:00"), QuietWindow("22:00", "02:30"), QuietWindow("23:30", "01:30"), QuietWindow("13:00", "14:00")]
125
+ starts = [datetime(2026, 3, 7, tzinfo=UTC), datetime(2026, 3, 28, tzinfo=UTC), datetime(2026, 10, 3, tzinfo=UTC), datetime(2026, 10, 31, tzinfo=UTC)]
126
+ compared = 0
127
+ for zone in zones:
128
+ for window in windows:
129
+ for start in starts:
130
+ for step in range(0, 48 * 60, 71):
131
+ now = start + timedelta(minutes=step, seconds=17)
132
+ minutes, day = local_clock(now, zone)
133
+ if quiet_at(window, day, minutes) is None:
134
+ continue
135
+ assert quiet_ends_at(window, zone, now) == first_open_minute(window, zone, now), (zone, window, now)
136
+ compared += 1
137
+ assert compared > 100
138
+
139
+
140
+ def test_quiet_ends_at_inside_the_second_pass_of_a_repeated_hour() -> None:
141
+ # 2026-11-01 01:00-02:00 happens twice in New York; 06:10 UTC is 01:10 EST, the second pass.
142
+ window = QuietWindow("23:00", "01:30")
143
+ now = datetime(2026, 11, 1, 6, 10, tzinfo=UTC)
144
+ expected = datetime(2026, 11, 1, 6, 30, tzinfo=UTC)
145
+ assert quiet_ends_at(window, "America/New_York", now) == expected
146
+ assert first_open_minute(window, "America/New_York", now) == expected
147
+
148
+
149
+ def test_quiet_ends_at_follows_a_schedule_and_gives_up_on_a_window_that_never_opens() -> None:
150
+ chain = QuietSchedule(days={"fri": QuietWindow("18:00", "00:00"), "sat": QuietWindow("00:00", "20:00")})
151
+ assert quiet_ends_at(chain, "Europe/Istanbul", datetime(2026, 9, 4, 16, 0, tzinfo=UTC)) == datetime(2026, 9, 5, 17, 0, tzinfo=UTC)
152
+ all_day = QuietSchedule(default=QuietWindow("00:00", "24:00"))
153
+ assert quiet_ends_at(all_day, "Europe/Istanbul", datetime(2026, 9, 4, 16, 0, tzinfo=UTC)) is None
154
+
155
+
156
+ KeyFor = Callable[[str, datetime, str | None], str]
157
+
158
+
159
+ def assert_resets_at_the_first_new_key(zone: str, now: datetime, key_for: KeyFor) -> None:
160
+ found = next_key_change(now, zone, lambda at: key_for("u", at, zone))
161
+ assert found is not None, (zone, now)
162
+ assert key_for("u", found, zone) != key_for("u", now, zone)
163
+ assert key_for("u", found - MINUTE, zone) == key_for("u", now, zone)
164
+
165
+
166
+ def test_next_key_change_is_the_first_minute_whose_key_differs() -> None:
167
+ assert_resets_at_the_first_new_key("America/Havana", datetime(2026, 3, 7, 20, 0, tzinfo=UTC), budget_key)
168
+ assert_resets_at_the_first_new_key("America/New_York", datetime(2026, 11, 1, 4, 30, tzinfo=UTC), budget_key)
169
+ assert_resets_at_the_first_new_key("America/New_York", datetime(2026, 3, 8, 5, 30, tzinfo=UTC), budget_key)
170
+ assert_resets_at_the_first_new_key("Pacific/Apia", datetime(2011, 12, 29, 10, 0, tzinfo=UTC), budget_key)
171
+ assert_resets_at_the_first_new_key("Europe/Istanbul", datetime(2026, 9, 2, 9, 0, tzinfo=UTC), weekly_budget_key)
172
+ assert_resets_at_the_first_new_key("Europe/Istanbul", datetime(2026, 9, 15, 9, 0, tzinfo=UTC), monthly_budget_key)
173
+ assert_resets_at_the_first_new_key("America/New_York", datetime(2026, 12, 31, 20, 0, tzinfo=UTC), monthly_budget_key)
174
+ utc = next_key_change(datetime(2026, 9, 4, 9, 0, 30, tzinfo=UTC), None, lambda at: budget_key("u", at, None))
175
+ assert utc == datetime(2026, 9, 5, tzinfo=UTC)