proactive-gate 0.2.1 → 0.2.3

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.
package/README.md CHANGED
@@ -100,14 +100,40 @@ something returned false".
100
100
  | 5 | `snooze()` | `user.snoozedUntil` is in the future | global pause |
101
101
  | 6 | `mute()` | `candidate.type` is in `user.mutedTypes` | per-type mute |
102
102
  | 7 | `intensity()` | priority is below the user's intensity floor | low hears only high, normal hears normal and up, high hears everything |
103
- | 8 | `quietHours({ priorityFloor })` | inside the user's local quiet window | IANA time zone, window may cross midnight, bypassed at or above the floor |
103
+ | 8 | `quietHours({ priorityFloor })` | inside the user's local quiet window | IANA time zone, window may cross midnight, bypassed at or above the floor; one window every day or [a schedule per day](#quiet-hours-that-differ-by-day) |
104
104
  | 9 | `trustRamp({ days, minPriority })` | user is newer than `days` and priority is below the floor | the system is least calibrated exactly when the user is least forgiving |
105
105
  | 10 | `dismissalCooldown({ dismissals, withinDays, silenceDays })` | the user dismissed that type `dismissals` times in the window | fed by `gate.record(user, candidate, "dismissed")`; every further dismissal restarts the silence |
106
106
  | 11 | `adaptiveTiming({ nextGoodMoment, surfacesFor })` | never | non-rejecting: moves `deliverAt` or narrows surfaces; a check marked `nonRejecting` cannot reject even if it tries |
107
107
  | 12 | `dailyBudget({ limit, bypassPriority })` | the user's local-day counter is at the limit | `evaluate` reads, `commit` increments atomically and can still refuse |
108
108
 
109
+ ### Quiet hours that differ by day
110
+
111
+ A working week is not Monday to Friday everywhere, and a holiday is not a weekday at all.
112
+ `quietHours` takes a schedule as well as a single window:
113
+
114
+ ```ts
115
+ quietHours: {
116
+ default: { start: "22:00", end: "08:00" },
117
+ days: { fri: { start: "00:00", end: "23:59" }, sat: { start: "00:00", end: "23:59" }, sun: null },
118
+ dates: { "2026-12-25": { start: "00:00", end: "23:59" } },
119
+ }
120
+ ```
121
+
122
+ A date beats a weekday beats the default, and `null` means the day has no quiet hours, which is
123
+ how a working day is carved out of a default. A window belongs to the day it opens on, so one
124
+ that crosses midnight silences the next morning and the reason names the day it came from.
125
+
126
+ Two things this deliberately does not do. There is no bundled holiday calendar: the dates you
127
+ observe are yours to supply, and a bundled one goes stale without anyone noticing. And one row
128
+ cannot express more than 24 hours, so a Friday evening to Saturday evening silence is two rows,
129
+ `fri: 18:00 to 00:00` and `sat: 00:00 to 20:00`.
130
+
131
+ Passing a single window is unchanged and remains the common case; a schedule whose every day
132
+ resolves to the same window behaves identically to that window.
133
+
109
134
  `weeklyBudget({ limit, bypassPriority })` is the same shape keyed on the user's local ISO
110
- week; `defaultChecks({ weeklyLimit })` places it just before the daily one. Budgets are
135
+ week; `defaultChecks({ weeklyLimit })` places it just before the daily one. It was contributed by
136
+ [@edwardsong08](https://github.com/edwardsong08) in [#9](https://github.com/Bubblegunn/proactive-gate/pull/9). Budgets are
111
137
  consumed in check order at commit, so when a weekly check passes and the daily one then
112
138
  refuses, that weekly unit is spent without a delivery. It only happens when two commits
113
139
  race after a shared evaluate.
@@ -349,11 +375,62 @@ if (decision.allowed && await gate.commit(decision, input)) { // INCR, returns
349
375
  counter is keyed on the user's local day, so a budget resets at the user's midnight,
350
376
  not at UTC.
351
377
 
378
+ ## One message per event, when the transport delivers twice
379
+
380
+ Message transports deliver at least once. A webhook that does not get its `200`
381
+ quickly enough is sent again; a queue hands the same event to two workers; a retry
382
+ after a timeout arrives after the first attempt already succeeded. The user sees the
383
+ same message twice and reads it as a broken assistant.
384
+
385
+ `dedupe` is the check for it, and it is off unless you ask for it:
386
+
387
+ ```ts
388
+ const gate = createGate({ checks: defaultChecks({ dedupe: true }), store });
389
+
390
+ await gate.evaluate({
391
+ user,
392
+ candidate: { id: crypto.randomUUID(), type: "shipping", dedupeKey: "order:42:shipped" },
393
+ });
394
+ ```
395
+
396
+ The key is yours because only you know what makes two attempts the same event.
397
+ Derive it from the event, `order:42:shipped`, not from a fresh identifier per attempt
398
+ and not from the message text, which usually carries a timestamp and so differs on
399
+ every retry. Without a `dedupeKey` the check skips and says so, rather than guessing a
400
+ key and silently doing nothing.
401
+
402
+ Three things about it are worth stating, because they are what a hand-rolled version
403
+ usually gets wrong.
404
+
405
+ **The claim is atomic and happens at commit.** Two workers holding the same event both
406
+ evaluate before either has recorded anything, so a check that only reads cannot tell
407
+ them apart. `dedupe` reads at evaluate and claims at commit with the same increment the
408
+ budgets use; only the caller that receives the first increment may send. A
409
+ read-then-write claim lets both through, and the spec calls that non-conforming.
410
+
411
+ **A suppressed duplicate does not cost a message.** `dedupe` consumes before the
412
+ budgets, so when it loses the race the gate stops there and the counter is never
413
+ incremented. The cost of that ordering, which is real: an event that clears `dedupe`
414
+ and is then refused by an exhausted budget has claimed its key for the rest of the
415
+ window.
416
+
417
+ **The window is fixed from the first claim, not sliding.** A stream of duplicates does
418
+ not push the expiry further out; every store here keeps the original expiry when a key
419
+ is incremented again.
420
+
421
+ The default window is 24 hours, which is the common retry horizon rather than a number
422
+ of ours: Stripe prunes an idempotency key ["after they're at least 24 hours
423
+ old"](https://docs.stripe.com/api/idempotent_requests), and Nylas gives the same figure
424
+ as the safe default for [webhook
425
+ deduplication](https://developer.nylas.com/docs/cookbook/agent-accounts/prevent-duplicate-replies/).
426
+ Set `windowSeconds` from your own transport's retry horizon.
427
+
352
428
  ## Stores
353
429
 
354
430
  `MemoryStore` keeps values in process memory and is useful for a single instance. `RedisStore`
355
431
  shares values across instances. `SqliteStore` persists values in a SQLite database without
356
- adding a package dependency. `SqliteStore` requires Node.js 22.5 or newer; the SQLite module
432
+ adding a package dependency. It was contributed by
433
+ [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) in [#3](https://github.com/Bubblegunn/proactive-gate/pull/3). `SqliteStore` requires Node.js 22.5 or newer; the SQLite module
357
434
  is loaded only when the store is constructed so the package can still be used on Node.js 20. On Node 22 the module prints an ExperimentalWarning on first use; it is stable from Node 24.
358
435
 
359
436
  ## Fail open, on purpose
@@ -472,6 +549,10 @@ preset) still refuses. Both are part of `npm run examples` and of the test suite
472
549
  pip install proactive-gate
473
550
  ```
474
551
 
552
+ To run an unreleased state, install from the repository instead: `pip install "proactive-gate @
553
+ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. The published release was
554
+ uploaded from a local build with a token, so unlike the npm package it carries no build provenance.
555
+
475
556
  ```python
476
557
  from proactive_gate import Gate
477
558
  gate = Gate.from_policy(policy) # the same policy.json
@@ -568,6 +649,15 @@ per-type switch and a priority floor that bypasses quiet time. Horvitz's work on
568
649
  initiative supplied the two optional checks. This package puts those ideas in one list with a
569
650
  trace, and adds the part they leave out: the budget consumed at send time.
570
651
 
652
+ ## Thanks
653
+
654
+ Two people sent pull requests on the day this was published, neither of whom I had spoken to
655
+ before. [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) wrote `SqliteStore`
656
+ ([#3](https://github.com/Bubblegunn/proactive-gate/pull/3)) and
657
+ [@edwardsong08](https://github.com/edwardsong08) wrote the weekly budget
658
+ ([#9](https://github.com/Bubblegunn/proactive-gate/pull/9)). Both shipped in 0.1.2 and are in
659
+ every release since, including the one you install today.
660
+
571
661
  ## Development
572
662
 
573
663
  ```
package/README.tr.md CHANGED
@@ -309,6 +309,10 @@ kurmak gerekmez. Her biri kapının gerekçesiyle reddeder ve onayda bütçeyi t
309
309
  pip install proactive-gate
310
310
  ```
311
311
 
312
+ Yayınlanmamış bir durumu denemek için depodan kurulur: `pip install "proactive-gate @
313
+ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. Yayınlanan sürüm yerel bir
314
+ derlemeden token ile yüklendi; npm paketinin aksine derleme kanıtı taşımıyor.
315
+
312
316
  `python/` sapan bir port değil, bir kardeştir: `spec/fixtures` altındaki her senaryoyu senkron
313
317
  `Gate` ve `AsyncGate` (Redis, `redis.asyncio` üzerinden) ile geçer; mypy strict, CI'da Python
314
318
  3.11 ve 3.13. Bkz. [`python/README.md`](python/README.md).
@@ -338,6 +342,54 @@ if (decision.allowed && await gate.commit(decision, input)) { // INCR, returns
338
342
  `RedisStore`, `INCR` kullanır ve günün TTL'sini ilk artırmada ekler. Sayaç kullanıcının yerel
339
343
  gününe göre anahtarlanır, bu yüzden bütçe UTC'de değil kullanıcının gece yarısında sıfırlanır.
340
344
 
345
+ ## Taşıma iki kez ilettiğinde tek mesaj
346
+
347
+ Mesaj taşımaları en az bir kez iletir. `200` yanıtını yeterince hızlı alamayan bir
348
+ webhook yeniden gönderilir, bir kuyruk aynı olayı iki işçiye verir, zaman aşımından
349
+ sonraki bir deneme ilki çoktan başarılı olmuşken gelir. Kullanıcı aynı mesajı iki kez
350
+ görür ve bunu bozuk bir asistan diye okur.
351
+
352
+ `dedupe` bunun için, ve istemediğiniz sürece kapalıdır:
353
+
354
+ ```ts
355
+ const gate = createGate({ checks: defaultChecks({ dedupe: true }), store });
356
+
357
+ await gate.evaluate({
358
+ user,
359
+ candidate: { id: crypto.randomUUID(), type: "shipping", dedupeKey: "order:42:shipped" },
360
+ });
361
+ ```
362
+
363
+ Anahtar sizin, çünkü iki denemeyi aynı olay yapan şeyi yalnızca siz bilirsiniz. Onu
364
+ olaydan türetin, `order:42:shipped` gibi; her deneme için yeni üretilen bir kimlikten
365
+ ya da mesaj metninden değil, çünkü metin genellikle bir zaman damgası taşır ve her
366
+ denemede farklı olur. `dedupeKey` yoksa kontrol bunu söyleyerek kenara çekilir, bir
367
+ anahtar uydurup sessizce hiçbir şey yapmaz.
368
+
369
+ Elle yazılmış sürümlerin genellikle yanlış yaptığı üç nokta var.
370
+
371
+ **Talep atomiktir ve commit anında olur.** Aynı olayı tutan iki işçi, henüz hiçbir şey
372
+ kaydedilmeden değerlendirir; yalnızca okuyan bir kontrol onları ayıramaz. `dedupe`
373
+ evaluate'te okur, commit'te bütçelerin kullandığı artırmayla talep eder ve yalnızca ilk
374
+ artırmayı alan gönderebilir. Önce oku sonra yaz biçiminde bir talep ikisini de geçirir
375
+ ve şartname bunu uygunsuz sayar.
376
+
377
+ **Bastırılan bir kopya mesaj hakkı harcamaz.** `dedupe` bütçelerden önce tüketir, bu
378
+ yüzden yarışı kaybettiğinde kapı orada durur ve sayaç hiç artmaz. Bu sıralamanın bedeli
379
+ de gerçek: `dedupe`'u geçip ardından tükenmiş bir bütçeye takılan bir olay, pencerenin
380
+ kalanı için anahtarını yakmış olur.
381
+
382
+ **Pencere ilk talepten itibaren sabittir, kaymaz.** Arka arkaya gelen kopyalar bitiş
383
+ zamanını ileri itmez; buradaki her mağaza, bir anahtar yeniden artırıldığında ilk
384
+ bitiş zamanını korur.
385
+
386
+ Varsayılan pencere 24 saat, ve bu bizim seçtiğimiz bir sayı değil, yaygın yeniden
387
+ deneme ufku: Stripe bir idempotency anahtarını ["en az 24 saatlik olduktan
388
+ sonra"](https://docs.stripe.com/api/idempotent_requests) siliyor, Nylas da webhook
389
+ [tekilleştirmesi](https://developer.nylas.com/docs/cookbook/agent-accounts/prevent-duplicate-replies/)
390
+ için aynı rakamı güvenli varsayılan olarak veriyor. `windowSeconds` değerini kendi
391
+ taşımanızın yeniden deneme ufkundan seçin.
392
+
341
393
  ## Bilerek açık başarısız olur
342
394
 
343
395
  Depoya bağlı bir kontrol hata fırlattığında (Redis düştü), varsayılan adayı geçirir ve ize
@@ -1,4 +1,4 @@
1
- import type { Check, CheckContext, Priority, Surface } from "./types.js";
1
+ import type { Check, CheckContext, Priority, QuietSchedule, QuietWindow, Surface, Weekday } from "./types.js";
2
2
  export declare const DAY_SECONDS: number;
3
3
  /** Local "HH:MM" and calendar day for an instant in an IANA zone, using Intl only. */
4
4
  export declare function localClock(now: Date, timezone: string): {
@@ -7,6 +7,30 @@ export declare function localClock(now: Date, timezone: string): {
7
7
  };
8
8
  /** True when `minutes` falls inside [start, end), where the window may cross midnight. */
9
9
  export declare function inWindow(minutes: number, start: number, end: number): boolean;
10
+ /**
11
+ * The weekday of a local calendar date, and the date before it.
12
+ *
13
+ * Both are pure calendar arithmetic on the "YYYY-MM-DD" that `localClock` already
14
+ * resolved through Intl, never arithmetic on an instant. That is what keeps zones
15
+ * with a 45-minute offset (Kathmandu, Chatham, Eucla) and every daylight-saving
16
+ * transition out of this: the offset was applied before we got here.
17
+ */
18
+ export declare function weekdayOf(day: string): Weekday;
19
+ export declare function dayBefore(day: string): string;
20
+ /** The window in force on one local date: a date beats a weekday beats the default. */
21
+ export declare function windowFor(quiet: QuietWindow | QuietSchedule, day: string): QuietWindow | null;
22
+ /**
23
+ * Whether a local time is inside quiet hours, and which day's window says so.
24
+ *
25
+ * A window that crosses midnight belongs to the day it opens on, so a time can be
26
+ * quiet because of yesterday: Friday 18:00 to 00:00 silences Saturday 00:00 too.
27
+ * With one window every day this reduces exactly to `inWindow`, which is why the
28
+ * single-window form keeps behaving as it did.
29
+ */
30
+ export declare function quietAt(quiet: QuietWindow | QuietSchedule, day: string, minutes: number): {
31
+ window: QuietWindow;
32
+ day: string;
33
+ } | null;
10
34
  /** A production hard-stop that silences every producer at once. */
11
35
  export declare function killSwitch(isOn: () => boolean | Promise<boolean>): Check;
12
36
  /** Consent comes before everything, or you have evaluated preferences for someone who never agreed. */
@@ -103,6 +127,42 @@ export declare function dailyBudget(options?: BudgetOptions): BudgetCheck;
103
127
  export declare function weeklyBudget(options?: BudgetOptions): BudgetCheck;
104
128
  /** At most `limit` deliveries per user per local calendar month. */
105
129
  export declare function monthlyBudget(options?: BudgetOptions): BudgetCheck;
130
+ export declare const dedupeKeyFor: (userId: string, key: string) => string;
131
+ /**
132
+ * One delivery per event per window, claimed atomically at commit time.
133
+ *
134
+ * Message transports deliver at least once. A webhook that does not get its 200
135
+ * quickly enough arrives again, and two workers can pick up the same event at the
136
+ * same moment. Both attempts then evaluate against a store where nothing has been
137
+ * recorded yet, so a check that only reads cannot separate them: it has to claim.
138
+ * `consume()` claims with an atomic increment, the same primitive the budgets use,
139
+ * and only the caller that gets the first increment may send.
140
+ *
141
+ * Order matters and is the reason this check consumes before the budgets: when it
142
+ * loses the race the gate stops there, so the duplicate never spends one of the
143
+ * user's messages for the day. The cost of that ordering, stated rather than hidden:
144
+ * the key is claimed before a later budget check runs, so an event that clears
145
+ * dedupe and is then refused by an exhausted budget has burnt its key for the rest
146
+ * of the window.
147
+ *
148
+ * The window is fixed from the first claim rather than sliding; every store here
149
+ * keeps the original expiry when a key is incremented again.
150
+ *
151
+ * `candidate.dedupeKey` is the caller's, because only the caller knows what makes
152
+ * two attempts the same event. Without it the check skips rather than guessing: a
153
+ * dedupe keyed on something unique per attempt silently does nothing, which is
154
+ * worse than not running.
155
+ *
156
+ * The 24-hour default is the common convention for how long a retry may arrive:
157
+ * Stripe prunes an idempotency key "after they're at least 24 hours old"
158
+ * (https://docs.stripe.com/api/idempotent_requests), and Nylas gives the same
159
+ * figure as the safe default for webhook deduplication
160
+ * (https://developer.nylas.com/docs/cookbook/agent-accounts/prevent-duplicate-replies/).
161
+ * Pick your own from your transport's retry horizon.
162
+ */
163
+ export declare function dedupe(options?: {
164
+ windowSeconds?: number;
165
+ }): Check;
106
166
  /**
107
167
  * Expected-utility alerting: act only when the caller's estimate of acceptance
108
168
  * clears tau = cFA / (cFA + pNeed * cFN). That threshold is the classical Bayes
@@ -190,4 +250,11 @@ export declare function defaultChecks(options?: {
190
250
  dailyLimit?: number;
191
251
  weeklyLimit?: number;
192
252
  quietHoursFloor?: Priority;
253
+ /**
254
+ * Add `dedupe` before the budgets. Off by default because it adds an entry to
255
+ * every trace; on, it costs nothing until a candidate carries a `dedupeKey`.
256
+ */
257
+ dedupe?: boolean | {
258
+ windowSeconds?: number;
259
+ };
193
260
  }): Check[];
@@ -39,6 +39,61 @@ export function inWindow(minutes, start, end) {
39
39
  return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
40
40
  }
41
41
  const localDay = (now, timezone) => (timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10));
42
+ const WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
43
+ /**
44
+ * The weekday of a local calendar date, and the date before it.
45
+ *
46
+ * Both are pure calendar arithmetic on the "YYYY-MM-DD" that `localClock` already
47
+ * resolved through Intl, never arithmetic on an instant. That is what keeps zones
48
+ * with a 45-minute offset (Kathmandu, Chatham, Eucla) and every daylight-saving
49
+ * transition out of this: the offset was applied before we got here.
50
+ */
51
+ export function weekdayOf(day) {
52
+ const [y, m, d] = day.split("-").map(Number);
53
+ return WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()];
54
+ }
55
+ export function dayBefore(day) {
56
+ const [y, m, d] = day.split("-").map(Number);
57
+ return new Date(Date.UTC(y, m - 1, d - 1)).toISOString().slice(0, 10);
58
+ }
59
+ const isSchedule = (q) => !("start" in q);
60
+ /** The window in force on one local date: a date beats a weekday beats the default. */
61
+ export function windowFor(quiet, day) {
62
+ if (!isSchedule(quiet))
63
+ return quiet;
64
+ const byDate = quiet.dates?.[day];
65
+ if (byDate !== undefined)
66
+ return byDate;
67
+ const byDay = quiet.days?.[weekdayOf(day)];
68
+ if (byDay !== undefined)
69
+ return byDay;
70
+ return quiet.default ?? null;
71
+ }
72
+ /**
73
+ * Whether a local time is inside quiet hours, and which day's window says so.
74
+ *
75
+ * A window that crosses midnight belongs to the day it opens on, so a time can be
76
+ * quiet because of yesterday: Friday 18:00 to 00:00 silences Saturday 00:00 too.
77
+ * With one window every day this reduces exactly to `inWindow`, which is why the
78
+ * single-window form keeps behaving as it did.
79
+ */
80
+ export function quietAt(quiet, day, minutes) {
81
+ const today = windowFor(quiet, day);
82
+ if (today) {
83
+ const start = parseHHMM(today.start);
84
+ const end = parseHHMM(today.end);
85
+ if (start !== end && (start < end ? minutes >= start && minutes < end : minutes >= start))
86
+ return { window: today, day };
87
+ }
88
+ const yesterday = windowFor(quiet, dayBefore(day));
89
+ if (yesterday) {
90
+ const start = parseHHMM(yesterday.start);
91
+ const end = parseHHMM(yesterday.end);
92
+ if (start > end && minutes < end)
93
+ return { window: yesterday, day: dayBefore(day) };
94
+ }
95
+ return null;
96
+ }
42
97
  /* ------------------------------------------------------------------------ */
43
98
  /* The checks, in the order LILA runs them. Compose your own order freely. */
44
99
  /* ------------------------------------------------------------------------ */
@@ -117,14 +172,16 @@ export function quietHours(options = {}) {
117
172
  return pass;
118
173
  if (!user.timezone)
119
174
  return skip("quiet hours set but no timezone on the user; cannot evaluate");
120
- const { minutes } = localClock(now, user.timezone);
121
- const start = parseHHMM(user.quietHours.start);
122
- const end = parseHHMM(user.quietHours.end);
123
- if (!inWindow(minutes, start, end))
175
+ const { minutes, day } = localClock(now, user.timezone);
176
+ const hit = quietAt(user.quietHours, day, minutes);
177
+ if (!hit)
124
178
  return pass;
125
179
  if (atLeast(priority, floor))
126
180
  return pass;
127
- return reject(`quiet hours ${user.quietHours.start} to ${user.quietHours.end} ${user.timezone}; priority ${priority} is below the floor (${floor})`);
181
+ // Name the day the window came from: when it crossed midnight the reason is
182
+ // yesterday's setting, and a reader looking at today's would not find it.
183
+ const whose = hit.day === day ? "" : ` (${weekdayOf(hit.day)} ${hit.day})`;
184
+ return reject(`quiet hours ${hit.window.start} to ${hit.window.end}${whose} ${user.timezone}; priority ${priority} is below the floor (${floor})`);
128
185
  },
129
186
  };
130
187
  }
@@ -270,6 +327,67 @@ export function weeklyBudget(options = {}) {
270
327
  export function monthlyBudget(options = {}) {
271
328
  return budget({ id: "monthlyBudget", label: "monthly budget", defaultLimit: 60, keyFor: ({ user, now }) => monthlyBudgetKey(user.id, now, user.timezone), ttlSeconds: 32 * DAY_SECONDS }, options);
272
329
  }
330
+ export const dedupeKeyFor = (userId, key) => `dedupe:${userId}:${key}`;
331
+ const humanWindow = (seconds) => {
332
+ if (seconds % DAY_SECONDS === 0)
333
+ return `${seconds / DAY_SECONDS}d`;
334
+ if (seconds % 3600 === 0)
335
+ return `${seconds / 3600}h`;
336
+ if (seconds % 60 === 0)
337
+ return `${seconds / 60}m`;
338
+ return `${seconds}s`;
339
+ };
340
+ /**
341
+ * One delivery per event per window, claimed atomically at commit time.
342
+ *
343
+ * Message transports deliver at least once. A webhook that does not get its 200
344
+ * quickly enough arrives again, and two workers can pick up the same event at the
345
+ * same moment. Both attempts then evaluate against a store where nothing has been
346
+ * recorded yet, so a check that only reads cannot separate them: it has to claim.
347
+ * `consume()` claims with an atomic increment, the same primitive the budgets use,
348
+ * and only the caller that gets the first increment may send.
349
+ *
350
+ * Order matters and is the reason this check consumes before the budgets: when it
351
+ * loses the race the gate stops there, so the duplicate never spends one of the
352
+ * user's messages for the day. The cost of that ordering, stated rather than hidden:
353
+ * the key is claimed before a later budget check runs, so an event that clears
354
+ * dedupe and is then refused by an exhausted budget has burnt its key for the rest
355
+ * of the window.
356
+ *
357
+ * The window is fixed from the first claim rather than sliding; every store here
358
+ * keeps the original expiry when a key is incremented again.
359
+ *
360
+ * `candidate.dedupeKey` is the caller's, because only the caller knows what makes
361
+ * two attempts the same event. Without it the check skips rather than guessing: a
362
+ * dedupe keyed on something unique per attempt silently does nothing, which is
363
+ * worse than not running.
364
+ *
365
+ * The 24-hour default is the common convention for how long a retry may arrive:
366
+ * Stripe prunes an idempotency key "after they're at least 24 hours old"
367
+ * (https://docs.stripe.com/api/idempotent_requests), and Nylas gives the same
368
+ * figure as the safe default for webhook deduplication
369
+ * (https://developer.nylas.com/docs/cookbook/agent-accounts/prevent-duplicate-replies/).
370
+ * Pick your own from your transport's retry horizon.
371
+ */
372
+ export function dedupe(options = {}) {
373
+ const windowSeconds = options.windowSeconds ?? DAY_SECONDS;
374
+ const label = humanWindow(windowSeconds);
375
+ return {
376
+ id: "dedupe",
377
+ async run({ user, candidate, store }) {
378
+ if (!candidate.dedupeKey)
379
+ return skip("no dedupeKey on the candidate; deduplication cannot be evaluated");
380
+ const seen = await store.get(dedupeKeyFor(user.id, candidate.dedupeKey));
381
+ return seen === null ? pass : reject(`already delivered within the last ${label}`);
382
+ },
383
+ async consume({ user, candidate, store }) {
384
+ if (!candidate.dedupeKey)
385
+ return true;
386
+ const claims = await store.incr(dedupeKeyFor(user.id, candidate.dedupeKey), windowSeconds);
387
+ return claims === 1;
388
+ },
389
+ };
390
+ }
273
391
  /* ------------------------------------------------------------------------ */
274
392
  /* Optional, caller-fed checks. Off by default; the package ships no model. */
275
393
  /* ------------------------------------------------------------------------ */
@@ -423,6 +541,7 @@ export function defaultChecks(options = {}) {
423
541
  trustRamp(),
424
542
  dismissalCooldown(),
425
543
  adaptiveTiming(),
544
+ ...(options.dedupe ? [dedupe(typeof options.dedupe === "object" ? options.dedupe : {})] : []),
426
545
  ...(options.weeklyLimit === undefined ? [] : [weeklyBudget({ limit: options.weeklyLimit })]),
427
546
  dailyBudget({ limit: options.dailyLimit ?? 5 }),
428
547
  ];
@@ -20,6 +20,7 @@ export const KNOWN_CHECKS = {
20
20
  trustRamp: (o) => checks.trustRamp({ ...opt(num(o, "days"), "days"), ...opt(prio(o, "minPriority"), "minPriority") }),
21
21
  dismissalCooldown: (o) => checks.dismissalCooldown({ ...opt(num(o, "dismissals"), "dismissals"), ...opt(num(o, "withinDays"), "withinDays"), ...opt(num(o, "silenceDays"), "silenceDays") }),
22
22
  adaptiveTiming: () => checks.adaptiveTiming(),
23
+ dedupe: (o) => checks.dedupe({ ...opt(num(o, "windowSeconds"), "windowSeconds") }),
23
24
  dailyBudget: (o) => checks.dailyBudget(budgetOptions(o)),
24
25
  weeklyBudget: (o) => checks.weeklyBudget(budgetOptions(o)),
25
26
  monthlyBudget: (o) => checks.monthlyBudget(budgetOptions(o)),
@@ -3,6 +3,27 @@ export type Priority = "low" | "normal" | "high" | "critical";
3
3
  export declare const PRIORITY_RANK: Record<Priority, number>;
4
4
  /** Where a delivery may land. Free-form so callers can add their own. */
5
5
  export type Surface = "feed" | "push" | "chat" | "voice" | "email" | (string & {});
6
+ /** A quiet window in local time, "HH:MM" to "HH:MM". `start` after `end` crosses midnight. */
7
+ export type QuietWindow = {
8
+ start: string;
9
+ end: string;
10
+ };
11
+ /** Weekday keys for a quiet-hours schedule, Sunday first to match `Date#getUTCDay`. */
12
+ export type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
13
+ /**
14
+ * Quiet hours that differ by day. A working week is not Monday to Friday everywhere,
15
+ * and a holiday is not a weekday at all, so the window is resolved per day: a calendar
16
+ * date first, then the weekday, then the default. `null` at any level means the day has
17
+ * no quiet hours.
18
+ *
19
+ * There is no bundled holiday calendar and there will not be one: the dates a caller
20
+ * observes are the caller's to supply, and a bundled calendar goes stale silently.
21
+ */
22
+ export type QuietSchedule = {
23
+ default?: QuietWindow | null;
24
+ days?: Partial<Record<Weekday, QuietWindow | null>>;
25
+ dates?: Record<string, QuietWindow | null>;
26
+ };
6
27
  /** Everything the gate knows about the person it might interrupt. */
7
28
  export interface UserState {
8
29
  id: string;
@@ -20,11 +41,25 @@ export interface UserState {
20
41
  intensity?: "low" | "normal" | "high";
21
42
  /** IANA time zone, required for quiet hours. */
22
43
  timezone?: string;
23
- /** Quiet hours in local time, "HH:MM". May cross midnight. */
24
- quietHours?: {
25
- start: string;
26
- end: string;
27
- } | null;
44
+ /**
45
+ * Quiet hours in local time, "HH:MM". May cross midnight.
46
+ *
47
+ * One window applies every day. A schedule gives a window per weekday, and per
48
+ * calendar date for the days a weekday cannot express, such as a public holiday:
49
+ *
50
+ * ```ts
51
+ * quietHours: {
52
+ * default: { start: "22:00", end: "08:00" },
53
+ * days: { fri: { start: "18:00", end: "00:00" }, sat: { start: "00:00", end: "20:00" } },
54
+ * dates: { "2026-12-25": { start: "00:00", end: "23:59" } },
55
+ * }
56
+ * ```
57
+ *
58
+ * `null` for a weekday or a date means no quiet hours that day, which is how you
59
+ * carve a working day out of a default. A date beats a weekday, a weekday beats
60
+ * the default. Dates are the user's local calendar dates, "YYYY-MM-DD".
61
+ */
62
+ quietHours?: QuietWindow | QuietSchedule | null;
28
63
  /** When the user joined. Drives the trust ramp. */
29
64
  createdAt?: Date | string;
30
65
  /** Surfaces the user allows, in preference order. Defaults to the candidate's surfaces. */
@@ -54,6 +89,16 @@ export interface Candidate {
54
89
  pAccept?: number;
55
90
  /** Caller-estimated probability the user needs it; utilityFloor reads it, default 1. */
56
91
  pNeed?: number;
92
+ /**
93
+ * Identity of the underlying event, not of this attempt. `dedupe` claims it once
94
+ * per window, so a webhook redelivered by an at-least-once transport, or the same
95
+ * event picked up by two workers, produces one message rather than two.
96
+ *
97
+ * Derive it from what makes the event the same event: `order:42:shipped`, not a
98
+ * fresh UUID per attempt and not the message text, which usually carries a
99
+ * timestamp and so differs on every retry.
100
+ */
101
+ dedupeKey?: string;
57
102
  /** Free-form payload; the gate never reads it. */
58
103
  payload?: unknown;
59
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks as code or JSON, a conformance spec, presets for platform and legal limits, adapters for AI SDK, Mastra, LangChain and OpenAI Agents, and a Python sibling.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -43,7 +43,7 @@
43
43
  "sideEffects": false,
44
44
  "scripts": {
45
45
  "build": "tsc -p tsconfig.json",
46
- "test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js test/release.test.mjs test/examples.test.mjs test/naive.test.mjs",
46
+ "test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js dist/test/dedupe.test.js test/release.test.mjs test/examples.test.mjs test/naive.test.mjs test/suite.test.mjs",
47
47
  "lint": "tsc -p tsconfig.json --noEmit",
48
48
  "spec-lint": "node test/spec-lint.mjs",
49
49
  "conformance": "npm run build && node dist/src/cli.js replay --fixtures spec/fixtures",