proactive-gate 0.2.2 → 0.2.4
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 +126 -2
- package/README.tr.md +48 -0
- package/dist/src/checks.d.ts +43 -0
- package/dist/src/checks.js +62 -0
- package/dist/src/policy.js +1 -0
- package/dist/src/types.d.ts +10 -0
- package/package.json +5 -3
- 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
|
@@ -132,7 +132,8 @@ Passing a single window is unchanged and remains the common case; a schedule who
|
|
|
132
132
|
resolves to the same window behaves identically to that window.
|
|
133
133
|
|
|
134
134
|
`weeklyBudget({ limit, bypassPriority })` is the same shape keyed on the user's local ISO
|
|
135
|
-
week; `defaultChecks({ weeklyLimit })` places it just before the daily one.
|
|
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
|
|
136
137
|
consumed in check order at commit, so when a weekly check passes and the daily one then
|
|
137
138
|
refuses, that weekly unit is spent without a delivery. It only happens when two commits
|
|
138
139
|
race after a shared evaluate.
|
|
@@ -374,11 +375,62 @@ if (decision.allowed && await gate.commit(decision, input)) { // INCR, returns
|
|
|
374
375
|
counter is keyed on the user's local day, so a budget resets at the user's midnight,
|
|
375
376
|
not at UTC.
|
|
376
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
|
+
|
|
377
428
|
## Stores
|
|
378
429
|
|
|
379
430
|
`MemoryStore` keeps values in process memory and is useful for a single instance. `RedisStore`
|
|
380
431
|
shares values across instances. `SqliteStore` persists values in a SQLite database without
|
|
381
|
-
adding a package dependency.
|
|
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
|
|
382
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.
|
|
383
435
|
|
|
384
436
|
## Fail open, on purpose
|
|
@@ -543,6 +595,69 @@ Python tests both run all of them; `npx proactive-gate replay --fixtures spec/fi
|
|
|
543
595
|
them from the command line. A third implementation starts from the fixtures, not from this
|
|
544
596
|
source.
|
|
545
597
|
|
|
598
|
+
The suite is an artifact, not a folder in this package. It is versioned by
|
|
599
|
+
[`spec/SPEC_VERSION`](spec/SPEC_VERSION) and tagged `spec/vX.Y.Z`, a series separate from the
|
|
600
|
+
package's release tags, so an implementation in any language can pin it without depending on npm
|
|
601
|
+
or PyPI:
|
|
602
|
+
|
|
603
|
+
```sh
|
|
604
|
+
git clone --depth 1 --branch spec/v1.2.0 https://github.com/Bubblegunn/proactive-gate
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
The npm package also ships it, so `node_modules/proactive-gate/spec/fixtures` exists after an
|
|
608
|
+
install. [`spec/CONFORMANCE.md`](spec/CONFORMANCE.md) states what passing means field by field,
|
|
609
|
+
and how to declare a skip: silence about a failing fixture is the one thing that makes a
|
|
610
|
+
conformance claim worthless.
|
|
611
|
+
|
|
612
|
+
<!-- conformance:start -->
|
|
613
|
+
Generated by `npm run conformance-table`; CI fails when it is stale.
|
|
614
|
+
|
|
615
|
+
| implementation | spec version | fixtures passed | declared skips |
|
|
616
|
+
|---|---|---:|---|
|
|
617
|
+
| TypeScript | 1.2.0 | 32 of 32 | none |
|
|
618
|
+
| Python | 1.2.0 | 32 of 32 | none |
|
|
619
|
+
<!-- conformance:end -->
|
|
620
|
+
|
|
621
|
+
### What made this work elsewhere, and why it might not here
|
|
622
|
+
|
|
623
|
+
The [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) is the
|
|
624
|
+
working example of a language-neutral fixture set becoming common ground: JSON files, a directory
|
|
625
|
+
per draft, consumed as "a git submodule or git subtree", an `optional/` directory for cases
|
|
626
|
+
implementations may decline, and validators in more than twenty languages held to it. What made it
|
|
627
|
+
work is that people were already implementing JSON Schema and already disagreeing about edge cases.
|
|
628
|
+
The suite settled arguments that existed.
|
|
629
|
+
|
|
630
|
+
This suite has no such argument to settle. Almost nobody has implemented notification gating twice,
|
|
631
|
+
so there is no disagreement waiting for a referee, and being early to a contract nobody adopts is
|
|
632
|
+
indistinguishable from being wrong. What the suite is worth today is narrower and still worth
|
|
633
|
+
having: it is why the Python package behaves like the TypeScript one, and it is what a third
|
|
634
|
+
implementation would be measured against rather than argued with.
|
|
635
|
+
|
|
636
|
+
Two implementations pass it and the same person wrote both, hours apart. That is a consistency
|
|
637
|
+
check, not independent verification, and the honest test is a third implementation written from
|
|
638
|
+
`SPEC.md` by someone who has not read this source. The method is in
|
|
639
|
+
[`docs/superpowers/specs/2026-09-05-proactive-gate-conformance-design.md`](docs/superpowers/specs/2026-09-05-proactive-gate-conformance-design.md).
|
|
640
|
+
|
|
641
|
+
### Where this sits next to MCP and A2A
|
|
642
|
+
|
|
643
|
+
Neither protocol answers the question this library answers, and both were read at the source rather
|
|
644
|
+
than summarised.
|
|
645
|
+
|
|
646
|
+
MCP's [elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation) is
|
|
647
|
+
the closest mechanism and it is complementary. It "provides a standardized way for servers to
|
|
648
|
+
request additional information from users through the client during interactions", and those
|
|
649
|
+
requests "occur *nested* inside other MCP server features". The user already started something and
|
|
650
|
+
the server needs input to finish it. Nothing there concerns being approached by an agent that
|
|
651
|
+
nobody asked: no quiet hours, no budget, no dismissal cooldown, no consent to be contacted.
|
|
652
|
+
|
|
653
|
+
A2A's [push notifications](https://a2a-protocol.org/latest/specification/) are transport. The
|
|
654
|
+
specification scopes them to server-to-server integrations, long-running tasks and event-driven
|
|
655
|
+
architectures, delivered by HTTP POST to client-registered webhook endpoints. Quiet hours, rate
|
|
656
|
+
limits and notification budgets do not appear in it.
|
|
657
|
+
|
|
658
|
+
Both answer how a message moves. Neither answers whether it should be sent now. That is the whole
|
|
659
|
+
claim, and it is all the reading supports.
|
|
660
|
+
|
|
546
661
|
## Performance
|
|
547
662
|
|
|
548
663
|
`npm run bench` runs `gate.evaluate()` ten thousand times with the default twelve checks and
|
|
@@ -597,6 +712,15 @@ per-type switch and a priority floor that bypasses quiet time. Horvitz's work on
|
|
|
597
712
|
initiative supplied the two optional checks. This package puts those ideas in one list with a
|
|
598
713
|
trace, and adds the part they leave out: the budget consumed at send time.
|
|
599
714
|
|
|
715
|
+
## Thanks
|
|
716
|
+
|
|
717
|
+
Two people sent pull requests on the day this was published, neither of whom I had spoken to
|
|
718
|
+
before. [@aaqib-hafeez-khan-in](https://github.com/aaqib-hafeez-khan-in) wrote `SqliteStore`
|
|
719
|
+
([#3](https://github.com/Bubblegunn/proactive-gate/pull/3)) and
|
|
720
|
+
[@edwardsong08](https://github.com/edwardsong08) wrote the weekly budget
|
|
721
|
+
([#9](https://github.com/Bubblegunn/proactive-gate/pull/9)). Both shipped in 0.1.2 and are in
|
|
722
|
+
every release since, including the one you install today.
|
|
723
|
+
|
|
600
724
|
## Development
|
|
601
725
|
|
|
602
726
|
```
|
package/README.tr.md
CHANGED
|
@@ -342,6 +342,54 @@ if (decision.allowed && await gate.commit(decision, input)) { // INCR, returns
|
|
|
342
342
|
`RedisStore`, `INCR` kullanır ve günün TTL'sini ilk artırmada ekler. Sayaç kullanıcının yerel
|
|
343
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.
|
|
344
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
|
+
|
|
345
393
|
## Bilerek açık başarısız olur
|
|
346
394
|
|
|
347
395
|
Depoya bağlı bir kontrol hata fırlattığında (Redis düştü), varsayılan adayı geçirir ve ize
|
package/dist/src/checks.d.ts
CHANGED
|
@@ -127,6 +127,42 @@ export declare function dailyBudget(options?: BudgetOptions): BudgetCheck;
|
|
|
127
127
|
export declare function weeklyBudget(options?: BudgetOptions): BudgetCheck;
|
|
128
128
|
/** At most `limit` deliveries per user per local calendar month. */
|
|
129
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;
|
|
130
166
|
/**
|
|
131
167
|
* Expected-utility alerting: act only when the caller's estimate of acceptance
|
|
132
168
|
* clears tau = cFA / (cFA + pNeed * cFN). That threshold is the classical Bayes
|
|
@@ -214,4 +250,11 @@ export declare function defaultChecks(options?: {
|
|
|
214
250
|
dailyLimit?: number;
|
|
215
251
|
weeklyLimit?: number;
|
|
216
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
|
+
};
|
|
217
260
|
}): Check[];
|
package/dist/src/checks.js
CHANGED
|
@@ -327,6 +327,67 @@ export function weeklyBudget(options = {}) {
|
|
|
327
327
|
export function monthlyBudget(options = {}) {
|
|
328
328
|
return budget({ id: "monthlyBudget", label: "monthly budget", defaultLimit: 60, keyFor: ({ user, now }) => monthlyBudgetKey(user.id, now, user.timezone), ttlSeconds: 32 * DAY_SECONDS }, options);
|
|
329
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
|
+
}
|
|
330
391
|
/* ------------------------------------------------------------------------ */
|
|
331
392
|
/* Optional, caller-fed checks. Off by default; the package ships no model. */
|
|
332
393
|
/* ------------------------------------------------------------------------ */
|
|
@@ -480,6 +541,7 @@ export function defaultChecks(options = {}) {
|
|
|
480
541
|
trustRamp(),
|
|
481
542
|
dismissalCooldown(),
|
|
482
543
|
adaptiveTiming(),
|
|
544
|
+
...(options.dedupe ? [dedupe(typeof options.dedupe === "object" ? options.dedupe : {})] : []),
|
|
483
545
|
...(options.weeklyLimit === undefined ? [] : [weeklyBudget({ limit: options.weeklyLimit })]),
|
|
484
546
|
dailyBudget({ limit: options.dailyLimit ?? 5 }),
|
|
485
547
|
];
|
package/dist/src/policy.js
CHANGED
|
@@ -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)),
|
package/dist/src/types.d.ts
CHANGED
|
@@ -89,6 +89,16 @@ export interface Candidate {
|
|
|
89
89
|
pAccept?: number;
|
|
90
90
|
/** Caller-estimated probability the user needs it; utilityFloor reads it, default 1. */
|
|
91
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;
|
|
92
102
|
/** Free-form payload; the gate never reads it. */
|
|
93
103
|
payload?: unknown;
|
|
94
104
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proactive-gate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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,13 +37,14 @@
|
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
39
|
"dist/src",
|
|
40
|
+
"spec",
|
|
40
41
|
"README.md",
|
|
41
42
|
"LICENSE"
|
|
42
43
|
],
|
|
43
44
|
"sideEffects": false,
|
|
44
45
|
"scripts": {
|
|
45
46
|
"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",
|
|
47
|
+
"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
48
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
48
49
|
"spec-lint": "node test/spec-lint.mjs",
|
|
49
50
|
"conformance": "npm run build && node dist/src/cli.js replay --fixtures spec/fixtures",
|
|
@@ -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
|