proactive-gate 0.2.3 → 0.2.5
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 +95 -15
- package/README.tr.md +56 -17
- package/package.json +4 -2
- package/spec/CONFORMANCE.md +83 -0
- package/spec/SPEC.md +140 -0
- package/spec/SPEC_VERSION +1 -0
- package/spec/fixtures/adaptive-timing/placeholder.json +58 -0
- package/spec/fixtures/budget/bypass-priority.json +100 -0
- package/spec/fixtures/budget/daily-atomic-commit.json +140 -0
- package/spec/fixtures/budget/near-limit.json +103 -0
- package/spec/fixtures/budget/race-second-commit-loses.json +93 -0
- package/spec/fixtures/budget/weekly-iso-week.json +134 -0
- package/spec/fixtures/consent/required.json +116 -0
- package/spec/fixtures/cooldown/three-dismissals.json +132 -0
- package/spec/fixtures/dedupe/already-delivered.json +87 -0
- package/spec/fixtures/dedupe/no-key-skips.json +49 -0
- package/spec/fixtures/defer/snooze-as-defer.json +138 -0
- package/spec/fixtures/mode/allow-list.json +139 -0
- package/spec/fixtures/ordering/kill-switch.json +56 -0
- package/spec/fixtures/ordering/short-circuit.json +235 -0
- package/spec/fixtures/policy/unknown-check-is-an-error.json +50 -0
- package/spec/fixtures/presets/cn-minor-mode.json +170 -0
- package/spec/fixtures/presets/kakao-brand-message.json +93 -0
- package/spec/fixtures/presets/kr-network-act-50.json +168 -0
- package/spec/fixtures/presets/telegram-bot.json +162 -0
- package/spec/fixtures/presets/us-tcpa.json +90 -0
- package/spec/fixtures/quiet-hours/apia.json +90 -0
- package/spec/fixtures/quiet-hours/caller-supplied-dates.json +197 -0
- package/spec/fixtures/quiet-hours/crosses-midnight-by-day.json +221 -0
- package/spec/fixtures/quiet-hours/dst-new-york.json +126 -0
- package/spec/fixtures/quiet-hours/istanbul.json +160 -0
- package/spec/fixtures/quiet-hours/wall-clock.json +94 -0
- package/spec/fixtures/quiet-hours/weekday-schedule.json +262 -0
- package/spec/fixtures/shadow/reject-continues.json +143 -0
- package/spec/fixtures/trust-ramp/first-week.json +154 -0
- package/spec/fixtures/utility/bounded-deferral-cap.json +60 -0
- package/spec/fixtures/utility/bounded-deferral.json +95 -0
- package/spec/fixtures/utility/floor.json +168 -0
- package/spec/schema/fixture.schema.json +58 -0
- package/spec/schema/policy.schema.json +23 -0
- package/spec/skip/python.txt +0 -0
- package/spec/skip/ts.txt +0 -0
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ English | [Türkçe](README.tr.md)
|
|
|
9
9
|
<img src="https://img.shields.io/bundlephobia/minzip/proactive-gate?style=flat-square&color=111111" alt="minzipped size">
|
|
10
10
|
<img src="https://img.shields.io/github/stars/Bubblegunn/proactive-gate?style=flat-square&color=111111" alt="stars">
|
|
11
11
|
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT">
|
|
12
|
+
<a href="https://doi.org/10.5281/zenodo.22393512"><img src="https://img.shields.io/badge/DOI-10.5281%2Fzenodo.22393512-111111?style=flat-square" alt="DOI"></a>
|
|
12
13
|
</p>
|
|
13
14
|
|
|
14
15
|
Decide whether a proactive AI agent may reach a user right now, and log why not.
|
|
@@ -138,21 +139,23 @@ consumed in check order at commit, so when a weekly check passes and the daily o
|
|
|
138
139
|
refuses, that weekly unit is spent without a delivery. It only happens when two commits
|
|
139
140
|
race after a shared evaluate.
|
|
140
141
|
|
|
141
|
-
###
|
|
142
|
+
### One limit you should know before you adopt this
|
|
142
143
|
|
|
143
|
-
|
|
144
|
+
It is not a bug, and it is pinned by tests so a future change has to be deliberate.
|
|
144
145
|
|
|
145
|
-
**The week is the ISO week, so the weekly budget refills on Monday.**
|
|
146
|
-
week
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
146
|
+
**The week is the ISO week, so the weekly budget refills on Monday.** Monday is not where
|
|
147
|
+
the week starts for most people: of the twenty most populous countries, CLDR gives Monday
|
|
148
|
+
to seven, Sunday to eleven and Saturday to two, which you can read yourself with
|
|
149
|
+
`new Intl.Locale("und-EG").getWeekInfo().firstDay`. Where the working week runs Sunday to
|
|
150
|
+
Thursday, an ISO refill lands one day in: a user who spends the budget on Sunday has it back
|
|
151
|
+
on Monday with four working days still to run. The key is ISO anyway, for two reasons. A
|
|
152
|
+
counter already in your store is keyed by it, and moving the key silently resets every user
|
|
153
|
+
mid-week. And the day the counter turns over is not the day the user is protected on: quiet
|
|
154
|
+
hours already read the user's own weekday, including a Friday or a Shabbat window, and they
|
|
155
|
+
are what decides when a notification is allowed. The budget only decides how many. If the ISO
|
|
156
|
+
week is wrong for your users, pass your own budget check keyed how you like; it is an object
|
|
157
|
+
with an `id` and a `run`, it composes in the order you choose, and the trace will show it
|
|
158
|
+
firing beside the built-in ones.
|
|
156
159
|
|
|
157
160
|
Order is a design decision and it should be visible. Consent has to come before
|
|
158
161
|
everything. Quiet hours have to come before the budget, or a rejected candidate
|
|
@@ -550,8 +553,10 @@ pip install proactive-gate
|
|
|
550
553
|
```
|
|
551
554
|
|
|
552
555
|
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
|
|
554
|
-
|
|
556
|
+
git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. The Python package is published by
|
|
557
|
+
the same workflow as the npm one, so it carries PyPI publish attestations naming the repository
|
|
558
|
+
and the workflow that built each file. Releases before 0.2.2 were uploaded from a local build with
|
|
559
|
+
a token and carry none.
|
|
555
560
|
|
|
556
561
|
```python
|
|
557
562
|
from proactive_gate import Gate
|
|
@@ -595,6 +600,69 @@ Python tests both run all of them; `npx proactive-gate replay --fixtures spec/fi
|
|
|
595
600
|
them from the command line. A third implementation starts from the fixtures, not from this
|
|
596
601
|
source.
|
|
597
602
|
|
|
603
|
+
The suite is an artifact, not a folder in this package. It is versioned by
|
|
604
|
+
[`spec/SPEC_VERSION`](spec/SPEC_VERSION) and tagged `spec/vX.Y.Z`, a series separate from the
|
|
605
|
+
package's release tags, so an implementation in any language can pin it without depending on npm
|
|
606
|
+
or PyPI:
|
|
607
|
+
|
|
608
|
+
```sh
|
|
609
|
+
git clone --depth 1 --branch spec/v1.2.0 https://github.com/Bubblegunn/proactive-gate
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
The npm package also ships it, so `node_modules/proactive-gate/spec/fixtures` exists after an
|
|
613
|
+
install. [`spec/CONFORMANCE.md`](spec/CONFORMANCE.md) states what passing means field by field,
|
|
614
|
+
and how to declare a skip: silence about a failing fixture is the one thing that makes a
|
|
615
|
+
conformance claim worthless.
|
|
616
|
+
|
|
617
|
+
<!-- conformance:start -->
|
|
618
|
+
Generated by `npm run conformance-table`; CI fails when it is stale.
|
|
619
|
+
|
|
620
|
+
| implementation | spec version | fixtures passed | declared skips |
|
|
621
|
+
|---|---|---:|---|
|
|
622
|
+
| TypeScript | 1.2.0 | 32 of 32 | none |
|
|
623
|
+
| Python | 1.2.0 | 32 of 32 | none |
|
|
624
|
+
<!-- conformance:end -->
|
|
625
|
+
|
|
626
|
+
### What made this work elsewhere, and why it might not here
|
|
627
|
+
|
|
628
|
+
The [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) is the
|
|
629
|
+
working example of a language-neutral fixture set becoming common ground: JSON files, a directory
|
|
630
|
+
per draft, consumed as "a git submodule or git subtree", an `optional/` directory for cases
|
|
631
|
+
implementations may decline, and validators in more than twenty languages held to it. What made it
|
|
632
|
+
work is that people were already implementing JSON Schema and already disagreeing about edge cases.
|
|
633
|
+
The suite settled arguments that existed.
|
|
634
|
+
|
|
635
|
+
This suite has no such argument to settle. Almost nobody has implemented notification gating twice,
|
|
636
|
+
so there is no disagreement waiting for a referee, and being early to a contract nobody adopts is
|
|
637
|
+
indistinguishable from being wrong. What the suite is worth today is narrower and still worth
|
|
638
|
+
having: it is why the Python package behaves like the TypeScript one, and it is what a third
|
|
639
|
+
implementation would be measured against rather than argued with.
|
|
640
|
+
|
|
641
|
+
Two implementations pass it and the same person wrote both, hours apart. That is a consistency
|
|
642
|
+
check, not independent verification, and the honest test is a third implementation written from
|
|
643
|
+
`SPEC.md` by someone who has not read this source. The method is in
|
|
644
|
+
[`docs/superpowers/specs/2026-09-05-proactive-gate-conformance-design.md`](docs/superpowers/specs/2026-09-05-proactive-gate-conformance-design.md).
|
|
645
|
+
|
|
646
|
+
### Where this sits next to MCP and A2A
|
|
647
|
+
|
|
648
|
+
Neither protocol answers the question this library answers, and both were read at the source rather
|
|
649
|
+
than summarised.
|
|
650
|
+
|
|
651
|
+
MCP's [elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation) is
|
|
652
|
+
the closest mechanism and it is complementary. It "provides a standardized way for servers to
|
|
653
|
+
request additional information from users through the client during interactions", and those
|
|
654
|
+
requests "occur *nested* inside other MCP server features". The user already started something and
|
|
655
|
+
the server needs input to finish it. Nothing there concerns being approached by an agent that
|
|
656
|
+
nobody asked: no quiet hours, no budget, no dismissal cooldown, no consent to be contacted.
|
|
657
|
+
|
|
658
|
+
A2A's [push notifications](https://a2a-protocol.org/latest/specification/) are transport. The
|
|
659
|
+
specification scopes them to server-to-server integrations, long-running tasks and event-driven
|
|
660
|
+
architectures, delivered by HTTP POST to client-registered webhook endpoints. Quiet hours, rate
|
|
661
|
+
limits and notification budgets do not appear in it.
|
|
662
|
+
|
|
663
|
+
Both answer how a message moves. Neither answers whether it should be sent now. That is the whole
|
|
664
|
+
claim, and it is all the reading supports.
|
|
665
|
+
|
|
598
666
|
## Performance
|
|
599
667
|
|
|
600
668
|
`npm run bench` runs `gate.evaluate()` ten thousand times with the default twelve checks and
|
|
@@ -658,6 +726,18 @@ before. [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) wrote `
|
|
|
658
726
|
([#9](https://github.com/Bubblegunn/proactive-gate/pull/9)). Both shipped in 0.1.2 and are in
|
|
659
727
|
every release since, including the one you install today.
|
|
660
728
|
|
|
729
|
+
## Cite this
|
|
730
|
+
|
|
731
|
+
Every release is archived on Zenodo with a DOI, so a paper or a report can point at the
|
|
732
|
+
exact code it ran.
|
|
733
|
+
|
|
734
|
+
[](https://doi.org/10.5281/zenodo.22393512)
|
|
735
|
+
|
|
736
|
+
That is the **concept** DOI: it always resolves to the newest version. To cite the exact
|
|
737
|
+
version you ran, open that page, pick the version in the sidebar, and use the DOI shown
|
|
738
|
+
there. `CITATION.cff` in this repository carries the same identifier, so GitHub's "Cite this
|
|
739
|
+
repository" button produces correct BibTeX and APA without any copying by hand.
|
|
740
|
+
|
|
661
741
|
## Development
|
|
662
742
|
|
|
663
743
|
```
|
package/README.tr.md
CHANGED
|
@@ -81,12 +81,37 @@ olurdu.
|
|
|
81
81
|
| 5 | `snooze()` | `user.snoozedUntil` gelecekteyse | genel duraklatma |
|
|
82
82
|
| 6 | `mute()` | `candidate.type`, `user.mutedTypes` içindeyse | tür bazlı susturma |
|
|
83
83
|
| 7 | `intensity()` | öncelik, kullanıcının yoğunluk tabanının altındaysa | low yalnızca high duyar, normal normal ve üstünü, high her şeyi |
|
|
84
|
-
| 8 | `quietHours({ priorityFloor })` | kullanıcının yerel sessiz penceresi içindeyse | IANA saat dilimi, pencere gece yarısını geçebilir, taban ve üstünde atlanır |
|
|
84
|
+
| 8 | `quietHours({ priorityFloor })` | kullanıcının yerel sessiz penceresi içindeyse | IANA saat dilimi, pencere gece yarısını geçebilir, taban ve üstünde atlanır; her gün tek pencere ya da [güne göre bir çizelge](#güne-göre-değişen-sessiz-saatler) |
|
|
85
85
|
| 9 | `trustRamp({ days, minPriority })` | kullanıcı `days` günden yeniyse ve öncelik tabanın altındaysa | sistem, kullanıcı en az bağışlayıcıyken en az kalibredir |
|
|
86
86
|
| 10 | `dismissalCooldown({ dismissals, withinDays, silenceDays })` | kullanıcı o türü pencere içinde `dismissals` kez reddettiyse | `gate.record(user, candidate, "dismissed")` ile beslenir; her yeni ret sessizliği yeniden başlatır |
|
|
87
87
|
| 11 | `adaptiveTiming({ nextGoodMoment, surfacesFor })` | asla | reddetmez: `deliverAt` değerini taşır ya da yüzeyleri daraltır; `nonRejecting` işaretli bir kontrol istese de reddedemez |
|
|
88
88
|
| 12 | `dailyBudget({ limit, bypassPriority })` | kullanıcının yerel gün sayacı sınırdaysa | `evaluate` okur, `commit` atomik artırır ve yine de reddedebilir |
|
|
89
89
|
|
|
90
|
+
### Güne göre değişen sessiz saatler
|
|
91
|
+
|
|
92
|
+
Çalışma haftası her yerde pazartesiden cumaya değildir ve tatil günü zaten hafta içi
|
|
93
|
+
değildir. `quietHours` tek bir pencerenin yanında bir çizelge de alır:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
quietHours: {
|
|
97
|
+
default: { start: "22:00", end: "08:00" },
|
|
98
|
+
days: { fri: { start: "00:00", end: "23:59" }, sat: { start: "00:00", end: "23:59" }, sun: null },
|
|
99
|
+
dates: { "2026-12-25": { start: "00:00", end: "23:59" } },
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Tarih haftanın gününü, o da varsayılanı yener; `null` o günün sessiz saati yok demektir ve
|
|
104
|
+
varsayılanın içinden bir iş günü böyle oyulur. Bir pencere açıldığı güne aittir, yani gece
|
|
105
|
+
yarısını geçen bir pencere ertesi sabahı susturur ve gerekçe hangi günden geldiğini söyler.
|
|
106
|
+
|
|
107
|
+
Bunun bilerek yapmadığı iki şey var. Gömülü bir tatil takvimi yok: hangi tarihleri
|
|
108
|
+
tuttuğunuz size aittir, gömülü olan ise kimse fark etmeden bayatlar. Ve tek bir satır 24
|
|
109
|
+
saatten fazlasını anlatamaz, yani cuma akşamından cumartesi akşamına uzanan bir sessizlik
|
|
110
|
+
iki satırdır: `fri: 18:00 to 00:00` ve `sat: 00:00 to 20:00`.
|
|
111
|
+
|
|
112
|
+
Tek pencere geçmek eskisi gibi çalışır ve yaygın durum olmayı sürdürür; her günü aynı
|
|
113
|
+
pencereye çıkan bir çizelge, o pencereyle birebir aynı davranır.
|
|
114
|
+
|
|
90
115
|
Sıra bir tasarım kararıdır ve görünür olmalıdır. Rıza her şeyden önce gelmelidir. Sessiz
|
|
91
116
|
saatler bütçeden önce gelmelidir, yoksa reddedilen bir aday hiç yapmadığı bir teslimi
|
|
92
117
|
tüketir. İstediğiniz gibi yeniden sıralayın; iz ne yaptığınızı gösterecektir.
|
|
@@ -227,22 +252,24 @@ işyerinde sürekli ulaşılabilir olmaları beklendiği için katılmayı redde
|
|
|
227
252
|
seçmediği bir sessizliğin bir bedeli var ve o bedel bu kütüphanenin yazdığı hiçbir izde
|
|
228
253
|
görünmüyor.
|
|
229
254
|
|
|
230
|
-
## Benimsemeden önce bilmeniz gereken
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
**Hafta, ISO haftasıdır; haftalık bütçe pazartesi yenilenir.**
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
255
|
+
## Benimsemeden önce bilmeniz gereken bir sınır
|
|
256
|
+
|
|
257
|
+
Hata değil ve testle sabitlendi, böylece ileride değişecekse bilerek değişir.
|
|
258
|
+
|
|
259
|
+
**Hafta, ISO haftasıdır; haftalık bütçe pazartesi yenilenir.** Pazartesi, çoğu insan için
|
|
260
|
+
haftanın başladığı gün değildir: en kalabalık yirmi ülkeden CLDR'ye göre yedisinde
|
|
261
|
+
pazartesi, on birinde pazar, ikisinde cumartesi başlar; bunu kendiniz
|
|
262
|
+
`new Intl.Locale("und-EG").getWeekInfo().firstDay` ile okuyabilirsiniz. Çalışma haftası
|
|
263
|
+
pazardan perşembeye uzanan yerlerde ISO yenilenmesi haftanın birinci gününe denk gelir:
|
|
264
|
+
pazar günü bütçesini harcayan bir kullanıcı pazartesi sabahı bütçesini geri alır ve önünde
|
|
265
|
+
hâlâ dört iş günü vardır. Anahtar yine de ISO, iki nedenle. Deponuzdaki sayaç bu anahtarla
|
|
266
|
+
tutuluyor ve anahtarı taşımak her kullanıcıyı haftanın ortasında sessizce sıfırlar. Ayrıca
|
|
267
|
+
sayacın döndüğü gün, kullanıcının korunduğu gün değildir: sessiz saatler kullanıcının kendi
|
|
268
|
+
haftalık gününü zaten okuyor, cuma ya da Şabat penceresi dahil, ve bildirimin ne zaman
|
|
269
|
+
verilebileceğine onlar karar veriyor. Bütçe yalnız kaç tane olacağına karar verir. ISO
|
|
270
|
+
haftası sizin kullanıcılarınız için yanlışsa kendi bütçe kontrolünüzü istediğiniz anahtarla
|
|
271
|
+
yazın: `id` ve `run` taşıyan bir nesnedir, istediğiniz sırada dizilir ve izde yerleşik
|
|
272
|
+
kontrollerin yanında görünür.
|
|
246
273
|
|
|
247
274
|
## Hazır paketler: platform kotaları ve yasal sınırlar, kaynaklarıyla
|
|
248
275
|
|
|
@@ -463,6 +490,18 @@ başına bir anahtar ve sessiz saati aşan bir öncelik tabanı verir. Horvitz'i
|
|
|
463
490
|
çalışmaları iki isteğe bağlı kontrolü sağladı. Bu paket o fikirleri izli tek bir listeye
|
|
464
491
|
koyar ve onların dışarıda bıraktığı parçayı ekler: gönderim anında tüketilen bütçe.
|
|
465
492
|
|
|
493
|
+
## Atıf
|
|
494
|
+
|
|
495
|
+
Her sürüm Zenodo'da bir DOI ile arşivleniyor, böylece bir makale ya da rapor tam olarak
|
|
496
|
+
çalıştırdığı koda işaret edebiliyor.
|
|
497
|
+
|
|
498
|
+
[](https://doi.org/10.5281/zenodo.22393512)
|
|
499
|
+
|
|
500
|
+
Bu **kavram** DOI'si: her zaman en yeni sürüme çözümlenir. Çalıştırdığınız sürümün kendisini
|
|
501
|
+
atıflamak için o sayfayı açıp yan çubuktan sürümü seçin ve orada yazan DOI'yi kullanın.
|
|
502
|
+
Depodaki `CITATION.cff` aynı tanımlayıcıyı taşıyor, bu yüzden GitHub'ın "Cite this repository"
|
|
503
|
+
düğmesi elle kopyalama olmadan doğru BibTeX ve APA üretiyor.
|
|
504
|
+
|
|
466
505
|
## Geliştirme
|
|
467
506
|
|
|
468
507
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proactive-gate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
39
|
"dist/src",
|
|
40
|
+
"spec",
|
|
40
41
|
"README.md",
|
|
41
42
|
"LICENSE"
|
|
42
43
|
],
|
|
@@ -53,7 +54,8 @@
|
|
|
53
54
|
"release": "node scripts/release.mjs",
|
|
54
55
|
"release-gate": "npm run build && node scripts/release-gate.mjs",
|
|
55
56
|
"trace-svg": "npm run build && node scripts/trace-svg.mjs",
|
|
56
|
-
"bench:compare": "npm run build && node bench/compare.mjs"
|
|
57
|
+
"bench:compare": "npm run build && node bench/compare.mjs",
|
|
58
|
+
"conformance-table": "node scripts/conformance-table.mjs"
|
|
57
59
|
},
|
|
58
60
|
"engines": {
|
|
59
61
|
"node": ">=20"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Claiming conformance
|
|
2
|
+
|
|
3
|
+
This directory is the contract. `SPEC.md` states the behaviour as numbered requirements and
|
|
4
|
+
`fixtures/` holds the cases that decide whether an implementation meets them. Both are
|
|
5
|
+
language-neutral: an implementation in any language can run them, and none of it depends on the
|
|
6
|
+
npm or PyPI packages.
|
|
7
|
+
|
|
8
|
+
## Getting the suite
|
|
9
|
+
|
|
10
|
+
The suite is versioned by `SPEC_VERSION`, and each version is tagged `spec/vX.Y.Z`, a series
|
|
11
|
+
separate from the package's own `vX.Y.Z` release tags.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
git clone --depth 1 --branch spec/v1.2.0 https://github.com/Bubblegunn/proactive-gate
|
|
15
|
+
# or, to keep it beside your own source and update it deliberately
|
|
16
|
+
git subtree add --prefix spec https://github.com/Bubblegunn/proactive-gate spec/v1.2.0 --squash
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
A JavaScript implementation can also read the fixtures from an install, because the npm package
|
|
20
|
+
ships this directory: `node_modules/proactive-gate/spec/fixtures`. The Python wheel does not ship
|
|
21
|
+
it; use git there.
|
|
22
|
+
|
|
23
|
+
## What passing means
|
|
24
|
+
|
|
25
|
+
An implementation conforms at version X when, for every fixture whose `spec_version` is X, every
|
|
26
|
+
assertion in every test's `expect` holds.
|
|
27
|
+
|
|
28
|
+
A fixture is a JSON document described by `schema/fixture.schema.json`. For each test, evaluate the
|
|
29
|
+
policy against the input at the given `now` and compare:
|
|
30
|
+
|
|
31
|
+
| Field | Comparison |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `allowed` | exact |
|
|
34
|
+
| `trace` | exact, the ordered list of check ids that ran |
|
|
35
|
+
| `rejectedBy`, `deferredBy` | exact, including absent |
|
|
36
|
+
| `retryAt`, `deliverAt` | exact, as an ISO instant ending `Z` |
|
|
37
|
+
| `surfaces`, `shadowed`, `nearLimit` | exact, when the fixture asserts them |
|
|
38
|
+
| `reason_pattern` | a regular expression that must match the decision's reason |
|
|
39
|
+
| `commit` | the boolean returned by committing the decision, when the test sets `commit` |
|
|
40
|
+
| `store_after` | exact, each key read from the store after the test, with the policy's key prefix |
|
|
41
|
+
|
|
42
|
+
`ms` on a trace entry is informative and is never asserted (`SPEC.md` 8.1). A fixture's
|
|
43
|
+
`store_seed` is written to the store before the tests run, with the same prefix.
|
|
44
|
+
|
|
45
|
+
Both existing runners work exactly this way, so this table describes the suite rather than adding a
|
|
46
|
+
second rule to it: see `src/conformance.ts` and `python/src/proactive_gate/conformance.py`.
|
|
47
|
+
|
|
48
|
+
## Declaring what you skip
|
|
49
|
+
|
|
50
|
+
Silence about a failing fixture is the one thing that makes a conformance claim worthless. Declare
|
|
51
|
+
skips in `skip/<impl>.txt`, one fixture name per line, with the reason after a `#`:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
quiet-hours/apia # no IANA time zone database on this platform
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`SPEC.md` requires that file to be empty at a stable release. Before then, the honest form of a
|
|
58
|
+
partial claim is "conforms to 1.2.0 except these fixtures, for these reasons", stated where a
|
|
59
|
+
reader will see it.
|
|
60
|
+
|
|
61
|
+
## Declaring the version you target
|
|
62
|
+
|
|
63
|
+
State the spec version in your own metadata, and assert in your continuous integration that it
|
|
64
|
+
equals the `SPEC_VERSION` in the suite you vendored. Both implementations here do that, and it is
|
|
65
|
+
what stops a suite from being updated underneath a claim.
|
|
66
|
+
|
|
67
|
+
## Adding to the suite
|
|
68
|
+
|
|
69
|
+
A fixture is a contract for every implementation, not only this one. So a change lands in
|
|
70
|
+
`SPEC.md`, in `fixtures/`, and in both implementations, or it does not land. New fixtures carry
|
|
71
|
+
`since` set to the version that introduced them, and `spec_version` set to the current one, which
|
|
72
|
+
`test/spec-lint.mjs` checks.
|
|
73
|
+
|
|
74
|
+
Versioning follows `SPEC.md`: a patch adds fixtures existing implementations already pass, a minor
|
|
75
|
+
adds a check or a field, a major changes an expectation.
|
|
76
|
+
|
|
77
|
+
## The honest status of this suite
|
|
78
|
+
|
|
79
|
+
Two implementations pass it, and the same person wrote both within hours of each other. That is
|
|
80
|
+
weaker evidence than it looks: agreement between two implementations by one author is closer to a
|
|
81
|
+
consistency check than to independent verification. A third implementation, written from `SPEC.md`
|
|
82
|
+
by someone who has not read the source, is what would test whether this document is enough. Until
|
|
83
|
+
that exists, treat the suite as a contract that has been used twice, not as a proven standard.
|
package/spec/SPEC.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# proactive-gate behaviour contract
|
|
2
|
+
|
|
3
|
+
Version: see `SPEC_VERSION`. The key words MUST, MUST NOT, SHOULD and MAY are to be read as in
|
|
4
|
+
RFC 2119. An implementation conforms when it passes every fixture under `fixtures/` for its
|
|
5
|
+
declared spec version, minus the fixtures listed in its `skip/<impl>.txt` file, which MUST be
|
|
6
|
+
empty at a stable release.
|
|
7
|
+
|
|
8
|
+
Versioning: patch releases add fixtures that existing implementations already pass; minor
|
|
9
|
+
releases add a check or field and mark it with `since`; major releases change an expectation.
|
|
10
|
+
An implementation declares the spec version it targets, and its CI MUST assert that the value
|
|
11
|
+
equals `SPEC_VERSION`.
|
|
12
|
+
|
|
13
|
+
## 1. Inputs
|
|
14
|
+
|
|
15
|
+
1.1 An evaluation input is a user, a candidate and an instant `now`. `now` MUST be supplied by
|
|
16
|
+
the caller in fixtures and MAY default to the current instant in library use.
|
|
17
|
+
|
|
18
|
+
1.2 A user has at least `id` and `consent`. Optional fields: `proactiveEnabled`, `mode`,
|
|
19
|
+
`snoozedUntil`, `mutedTypes`, `intensity` (low, normal, high), `timezone` (IANA), `quietHours`
|
|
20
|
+
(a window, or a schedule; see 6.3), `createdAt`, `surfaces`, `consents` (map of
|
|
21
|
+
name to boolean), `lastInboundAt`, `minor`, `existingCustomer`.
|
|
22
|
+
|
|
23
|
+
1.3 A candidate has at least `id` and `type`. Optional: `priority` (low, normal, high,
|
|
24
|
+
critical; default normal), `surfaces`, `channel`, `busy`, `pAccept`, `pNeed`, `payload`.
|
|
25
|
+
|
|
26
|
+
1.4 An implementation MUST NOT read `payload`.
|
|
27
|
+
|
|
28
|
+
## 2. Evaluation order and short circuit
|
|
29
|
+
|
|
30
|
+
2.1 An implementation MUST run checks in policy order and MUST stop at the first check whose
|
|
31
|
+
outcome is `reject` or `defer` and which is not in shadow mode.
|
|
32
|
+
|
|
33
|
+
2.2 The trace MUST list every check that ran, in order, with its outcome kind. Checks after the
|
|
34
|
+
stopping check MUST NOT appear.
|
|
35
|
+
|
|
36
|
+
2.3 The surfaces of an allowed decision start as the candidate's surfaces (default `["feed"]`)
|
|
37
|
+
filtered by the user's allowed surfaces when the user lists any, and MAY be narrowed by
|
|
38
|
+
`adjust` outcomes.
|
|
39
|
+
|
|
40
|
+
## 3. Outcomes
|
|
41
|
+
|
|
42
|
+
3.1 A check returns exactly one of `pass`, `reject` (with a reason), `adjust` (reason, optional
|
|
43
|
+
`deliverAt`, optional `surfaces`), `skip` (reason), or `defer` (reason and `retryAt`).
|
|
44
|
+
|
|
45
|
+
3.2 A check marked non-rejecting that returns `reject` MUST be recorded as `skip` and MUST NOT
|
|
46
|
+
stop evaluation.
|
|
47
|
+
|
|
48
|
+
3.3 `defer` produces a decision with `allowed` false, `deferredBy` set to the check id and
|
|
49
|
+
`retryAt` set to the instant the check supplied. `rejectedBy` MUST be absent.
|
|
50
|
+
|
|
51
|
+
3.4 A `pass` MAY carry `nearLimit` with `used` and `limit`; the decision lists every such entry
|
|
52
|
+
in order.
|
|
53
|
+
|
|
54
|
+
3.5 A check that throws MUST be recorded as `skip` and evaluation continues when the gate fails
|
|
55
|
+
open, or as `reject` and evaluation stops when it fails closed. The default is open.
|
|
56
|
+
|
|
57
|
+
## 4. Shadow mode
|
|
58
|
+
|
|
59
|
+
4.1 A check with `shadow` true that returns `reject` or `defer` MUST be recorded in the trace
|
|
60
|
+
with its real outcome kind and `shadow` true, its id MUST be appended to `shadowed`, and
|
|
61
|
+
evaluation MUST continue as if it had passed.
|
|
62
|
+
|
|
63
|
+
## 5. Store keys and atomic commit
|
|
64
|
+
|
|
65
|
+
5.1 Keys, before the implementation's prefix (default `pg:`):
|
|
66
|
+
`budget:<userId>:<YYYY-MM-DD>` local day, `weeklyBudget:<userId>:<YYYY>-W<WW>` ISO week of the
|
|
67
|
+
local day, `monthlyBudget:<userId>:<YYYY-MM>`, `cooldown:<userId>:<type>` (JSON array of epoch
|
|
68
|
+
milliseconds), `rate:<scope>:<window>` for rate limits, `windowBudget:<userId>:<epochSeconds of
|
|
69
|
+
lastInboundAt>`, `commit:<decisionId>`.
|
|
70
|
+
|
|
71
|
+
5.2 Budget checks read the counter at evaluate and MUST NOT increment it. `commit` MUST
|
|
72
|
+
increment atomically, in check order, and return false when a counter exceeds its limit.
|
|
73
|
+
|
|
74
|
+
5.3 `commit` MUST be idempotent on the decision id: a second call returns the first result
|
|
75
|
+
without incrementing.
|
|
76
|
+
|
|
77
|
+
5.4 `commit` on a decision that is not allowed MUST return false without touching the store.
|
|
78
|
+
|
|
79
|
+
5.5 `dedupe` keys as `dedupe:<userId>:<candidate.dedupeKey>`. With no `dedupeKey` on the
|
|
80
|
+
candidate it MUST skip, not pass silently: a deduplication keyed on something unique per
|
|
81
|
+
attempt does nothing, and an implementation that guessed a key would hide that.
|
|
82
|
+
|
|
83
|
+
5.6 `dedupe` MUST NOT claim at evaluate. It reads the key at evaluate and rejects when the key
|
|
84
|
+
is present; it claims at commit with the same atomic increment the budgets use, and only the
|
|
85
|
+
caller receiving the first increment may deliver. Two callers evaluating the same event
|
|
86
|
+
concurrently therefore both pass the check and exactly one commit succeeds. A read-then-write
|
|
87
|
+
claim is non-conforming.
|
|
88
|
+
|
|
89
|
+
5.7 Where `dedupe` and a budget are both present, `dedupe` MUST consume first, so a suppressed
|
|
90
|
+
duplicate does not spend a budget unit. The consequence, which implementations MUST NOT hide:
|
|
91
|
+
an event that clears `dedupe` and is then refused by an exhausted budget has claimed its key
|
|
92
|
+
for the remainder of the window.
|
|
93
|
+
|
|
94
|
+
5.8 The deduplication window is fixed from the first claim, not sliding. Incrementing an
|
|
95
|
+
existing key MUST NOT extend its expiry.
|
|
96
|
+
|
|
97
|
+
## 6. Clock and time zones
|
|
98
|
+
|
|
99
|
+
6.1 `now` is an instant. Local day, minutes and ISO week are derived from `now` in the user's
|
|
100
|
+
IANA zone; without a zone, UTC.
|
|
101
|
+
|
|
102
|
+
6.2 A check MUST NOT read a wall clock. Fixtures with `now` far in the future only pass when
|
|
103
|
+
`now` is honoured.
|
|
104
|
+
|
|
105
|
+
6.3 Quiet hours use `[start, end)` and may cross midnight; `start == end` is an empty window.
|
|
106
|
+
|
|
107
|
+
6.4 `quietHours` is either a window (`start`, `end` as `HH:MM`) or a schedule (since 1.1.0) with
|
|
108
|
+
optional `default` (a window or null), `days` (a map of `sun` to `sat` to a window or null) and
|
|
109
|
+
`dates` (a map of `YYYY-MM-DD` in the user's zone to a window or null). A window applies on every
|
|
110
|
+
day; a schedule resolves one window per local date, and an implementation MUST resolve it as
|
|
111
|
+
`dates[date]`, else `days[weekday(date)]`, else `default`, else none, where a present key whose
|
|
112
|
+
value is null means the day has no quiet hours.
|
|
113
|
+
|
|
114
|
+
6.5 A window belongs to the day it opens on. An implementation MUST treat a local time as quiet
|
|
115
|
+
when the window resolved for that local date contains it, or when the window resolved for the
|
|
116
|
+
previous local date crosses midnight and the time is before its `end`. The day resolved for the
|
|
117
|
+
current date takes precedence when both apply. A schedule whose every day resolves to the same
|
|
118
|
+
window MUST behave identically to that window given directly.
|
|
119
|
+
|
|
120
|
+
6.6 The weekday of a local date MUST be derived from the local calendar date, not from an
|
|
121
|
+
instant, so that a zone with an offset that is not a whole hour and a daylight-saving transition
|
|
122
|
+
cannot change it.
|
|
123
|
+
|
|
124
|
+
6.7 An implementation MUST NOT ship a calendar of holidays. `dates` is supplied by the caller.
|
|
125
|
+
|
|
126
|
+
## 7. Policy document
|
|
127
|
+
|
|
128
|
+
7.1 A policy is JSON with `specVersion`, optional `onStoreError`, optional `keyPrefix` and an
|
|
129
|
+
ordered `checks` array. An entry is `{ "id": <check>, ...options, "shadow"?: bool }` or
|
|
130
|
+
`{ "preset": <name>, ...options, "shadow"?: bool }`.
|
|
131
|
+
|
|
132
|
+
7.2 A preset entry expands in place to the preset's ordered checks.
|
|
133
|
+
|
|
134
|
+
7.3 An unknown check id or preset name MUST be rejected when the policy is compiled, naming the
|
|
135
|
+
known ids.
|
|
136
|
+
|
|
137
|
+
## 8. Trace
|
|
138
|
+
|
|
139
|
+
8.1 Each trace entry has `id`, `outcome`, optional `reason`, `ms`, optional `shadow`. `ms` is
|
|
140
|
+
informative and MUST NOT appear in fixtures.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.2.0
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"spec_version": "1.2.0",
|
|
3
|
+
"since": "1.0.0",
|
|
4
|
+
"name": "adaptive-timing/placeholder",
|
|
5
|
+
"description": "A JSON policy cannot carry a next-good-moment function; the placeholder passes and keeps the trace shape.",
|
|
6
|
+
"policy": {
|
|
7
|
+
"specVersion": "1.0.0",
|
|
8
|
+
"checks": [
|
|
9
|
+
{
|
|
10
|
+
"id": "consent"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "adaptiveTiming"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "dailyBudget"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"tests": [
|
|
21
|
+
{
|
|
22
|
+
"description": "passes",
|
|
23
|
+
"input": {
|
|
24
|
+
"user": {
|
|
25
|
+
"id": "u1",
|
|
26
|
+
"consent": true,
|
|
27
|
+
"proactiveEnabled": true,
|
|
28
|
+
"mode": "normal",
|
|
29
|
+
"intensity": "normal",
|
|
30
|
+
"timezone": "Europe/Istanbul",
|
|
31
|
+
"quietHours": {
|
|
32
|
+
"start": "22:00",
|
|
33
|
+
"end": "08:00"
|
|
34
|
+
},
|
|
35
|
+
"createdAt": "2026-01-01T00:00:00Z"
|
|
36
|
+
},
|
|
37
|
+
"candidate": {
|
|
38
|
+
"id": "c1",
|
|
39
|
+
"type": "reminder",
|
|
40
|
+
"priority": "normal",
|
|
41
|
+
"surfaces": [
|
|
42
|
+
"push",
|
|
43
|
+
"feed"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"now": "2026-09-04T09:00:00Z"
|
|
47
|
+
},
|
|
48
|
+
"expect": {
|
|
49
|
+
"allowed": true,
|
|
50
|
+
"trace": [
|
|
51
|
+
"consent",
|
|
52
|
+
"adaptiveTiming",
|
|
53
|
+
"dailyBudget"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|