proactive-gate 0.4.1 → 0.5.0
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 +19 -13
- package/README.tr.md +10 -3
- package/dist/src/checks.js +3 -1
- package/dist/src/explain.js +8 -2
- package/dist/src/presets.js +16 -2
- package/package.json +1 -1
- package/spec/CONFORMANCE.md +5 -3
- package/spec/SPEC.md +3 -0
- package/spec/SPEC_VERSION +1 -1
- package/spec/fixtures/adaptive-timing/placeholder.json +1 -1
- package/spec/fixtures/budget/bypass-priority.json +1 -1
- package/spec/fixtures/budget/daily-atomic-commit.json +1 -1
- package/spec/fixtures/budget/near-limit.json +1 -1
- package/spec/fixtures/budget/race-second-commit-loses.json +1 -1
- package/spec/fixtures/budget/weekly-iso-week.json +1 -1
- package/spec/fixtures/consent/required.json +1 -1
- package/spec/fixtures/cooldown/three-dismissals.json +1 -1
- package/spec/fixtures/dedupe/already-delivered.json +1 -1
- package/spec/fixtures/dedupe/no-key-skips.json +1 -1
- package/spec/fixtures/defer/snooze-as-defer.json +1 -1
- package/spec/fixtures/mode/allow-list.json +1 -1
- package/spec/fixtures/ordering/kill-switch.json +1 -1
- package/spec/fixtures/ordering/short-circuit.json +1 -1
- package/spec/fixtures/policy/unknown-check-is-an-error.json +1 -1
- package/spec/fixtures/presets/cn-minor-mode.json +1 -1
- package/spec/fixtures/presets/in-tcccp.json +258 -0
- package/spec/fixtures/presets/kakao-brand-message.json +1 -1
- package/spec/fixtures/presets/kr-network-act-50.json +1 -1
- package/spec/fixtures/presets/telegram-bot.json +1 -1
- package/spec/fixtures/presets/us-tcpa.json +1 -1
- package/spec/fixtures/quiet-hours/apia.json +1 -1
- package/spec/fixtures/quiet-hours/caller-supplied-dates.json +1 -1
- package/spec/fixtures/quiet-hours/crosses-midnight-by-day.json +1 -1
- package/spec/fixtures/quiet-hours/dst-new-york.json +1 -1
- package/spec/fixtures/quiet-hours/istanbul.json +1 -1
- package/spec/fixtures/quiet-hours/wall-clock.json +1 -1
- package/spec/fixtures/quiet-hours/weekday-schedule.json +1 -1
- package/spec/fixtures/shadow/reject-continues.json +1 -1
- package/spec/fixtures/trust-ramp/first-week.json +1 -1
- package/spec/fixtures/utility/bounded-deferral-cap.json +1 -1
- package/spec/fixtures/utility/bounded-deferral.json +1 -1
- package/spec/fixtures/utility/floor.json +1 -1
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ npx proactive-gate init --preset usTcpa --framework mastra
|
|
|
44
44
|
|
|
45
45
|
That writes `proactive-gate.policy.json` with the ten checks in order, appends the
|
|
46
46
|
preset you named, and prints the preset's own source next to the few lines that plug
|
|
47
|
-
the gate into that framework. `npx proactive-gate init --list` shows the
|
|
47
|
+
the gate into that framework. `npx proactive-gate init --list` shows the fifteen
|
|
48
48
|
platform and legal presets and the four frameworks.
|
|
49
49
|
|
|
50
50
|
If you would rather see the argument than read it: `npm run bench:compare` replays a
|
|
@@ -361,6 +361,7 @@ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessag
|
|
|
361
361
|
| `krNetworkAct50` | advertising consent, plus night consent for 21:00 to 08:00 local |
|
|
362
362
|
| `jpAntiSpamLaw` | opt-in |
|
|
363
363
|
| `cnMinorMode` | for minors: 06:00 to 22:00 Asia/Shanghai and one a day |
|
|
364
|
+
| `inTcccp` | promotional consent; the default-off bands 00:00 to 10:00 and 21:00 to 24:00 need their own opt-in |
|
|
364
365
|
| `usTcpa` | 08:00 to 21:00 at the user's local time (47 CFR 64.1200) |
|
|
365
366
|
| `euEprivacy` | marketing consent with the soft opt-in for existing customers |
|
|
366
367
|
| `telegramBot` | 1 a second and 20 a minute per chat |
|
|
@@ -371,23 +372,28 @@ out. Reviewable defaults, not legal advice: several official sources disagree wi
|
|
|
371
372
|
and the note says which value was chosen and why.
|
|
372
373
|
|
|
373
374
|
**Read the scope before you reach for a legal preset.** Every instrument above regulates
|
|
374
|
-
*commercial* communication. `usTcpa`, `euEprivacy`, `krNetworkAct50
|
|
375
|
-
marketing rules, so they bind your message only when the message itself is
|
|
376
|
-
reminder your user asked for is not advertising, and pulling in a marketing preset for it
|
|
375
|
+
*commercial* communication. `usTcpa`, `euEprivacy`, `krNetworkAct50`, `jpAntiSpamLaw` and
|
|
376
|
+
`inTcccp` are marketing rules, so they bind your message only when the message itself is
|
|
377
|
+
commercial. A reminder your user asked for is not advertising, and pulling in a marketing preset for it
|
|
377
378
|
imports a restriction the law never placed on you, which is its own kind of wrong answer.
|
|
378
379
|
Use them when the candidate is promotional; when it is not, the platform quotas and your own
|
|
379
|
-
quiet hours are the honest constraints.
|
|
380
|
+
quiet hours are the honest constraints. A preset that reads the recipient's own zone
|
|
381
|
+
(`usTcpa`, `krNetworkAct50`, `inTcccp`) has no local time to compare without `user.timezone`,
|
|
382
|
+
so those checks skip and the message goes out; each preset's note says so, and a user with no
|
|
383
|
+
zone is the case to handle before you rely on one of them.
|
|
380
384
|
|
|
381
385
|
That scope test is also why some jurisdictions people ask for are missing. Canada's CASL and
|
|
382
386
|
Australia's Spam Act 2003 set consent, identification and unsubscribe duties, and neither
|
|
383
387
|
carries a time-of-day rule at all. The Brazilian window quoted around the web comes from bill
|
|
384
388
|
PLS 48/2018, a proposal rather than enacted law, and it covers telemarketing calls. India is
|
|
385
|
-
the interesting one
|
|
386
|
-
Telecom Commercial Communications Customer Preference Regulations make time bands a
|
|
389
|
+
the interesting one, and the widely repeated "9am to 9pm" is not what the primary text says.
|
|
390
|
+
The Telecom Commercial Communications Customer Preference Regulations make time bands a
|
|
387
391
|
*preference the subscriber registers* with their access provider, alongside content category
|
|
388
392
|
and day type, not a fixed statutory quiet window, and the secondary sources that quote a
|
|
389
|
-
window disagree with each other about whether it starts at 09:00 or 10:00.
|
|
390
|
-
|
|
393
|
+
window disagree with each other about whether it starts at 09:00 or 10:00. What the
|
|
394
|
+
regulation does fix is the default: four of the nine Schedule-II bands, covering 00:00 to
|
|
395
|
+
10:00 and 21:00 to 24:00, are off for every customer until the subscriber switches that band
|
|
396
|
+
on, so `inTcccp` encodes those as one opt-in consent per band rather than a hard window.
|
|
391
397
|
|
|
392
398
|
## The budget is enforced at commit, not at evaluate
|
|
393
399
|
|
|
@@ -653,7 +659,7 @@ reproduces exactly. The race property was checked against a mutant: rewriting
|
|
|
653
659
|
[`spec/SPEC.md`](spec/SPEC.md) states the behaviour as numbered requirements, and
|
|
654
660
|
[`spec/fixtures`](spec/fixtures) holds language-neutral cases: the DST edge in
|
|
655
661
|
America/New_York, Pacific/Apia, a wall-clock case in 2031, atomic commit, the ISO week,
|
|
656
|
-
deferral, shadow mode, the optional checks and
|
|
662
|
+
deferral, shadow mode, the optional checks and six presets. The TypeScript tests and the
|
|
657
663
|
Python tests both run all of them; `npx proactive-gate replay --fixtures spec/fixtures` runs
|
|
658
664
|
them from the command line. A third implementation starts from the fixtures, not from this
|
|
659
665
|
source.
|
|
@@ -664,7 +670,7 @@ package's release tags, so an implementation in any language can pin it without
|
|
|
664
670
|
or PyPI:
|
|
665
671
|
|
|
666
672
|
```sh
|
|
667
|
-
git clone --depth 1 --branch spec/v1.
|
|
673
|
+
git clone --depth 1 --branch spec/v1.3.0 https://github.com/Bubblegunn/proactive-gate
|
|
668
674
|
```
|
|
669
675
|
|
|
670
676
|
The npm package also ships it, so `node_modules/proactive-gate/spec/fixtures` exists after an
|
|
@@ -677,8 +683,8 @@ Generated by `npm run conformance-table`; CI fails when it is stale.
|
|
|
677
683
|
|
|
678
684
|
| implementation | spec version | fixtures passed | declared skips |
|
|
679
685
|
|---|---|---:|---|
|
|
680
|
-
| TypeScript | 1.
|
|
681
|
-
| Python | 1.
|
|
686
|
+
| TypeScript | 1.3.0 | 33 of 33 | none |
|
|
687
|
+
| Python | 1.3.0 | 33 of 33 | none |
|
|
682
688
|
<!-- conformance:end -->
|
|
683
689
|
|
|
684
690
|
### What made this work elsewhere, and why it might not here
|
package/README.tr.md
CHANGED
|
@@ -323,6 +323,7 @@ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessag
|
|
|
323
323
|
| `krNetworkAct50` | reklam rızası, ayrıca 21:00 ile 08:00 yerel saat için gece rızası |
|
|
324
324
|
| `jpAntiSpamLaw` | opt-in |
|
|
325
325
|
| `cnMinorMode` | reşit olmayanlar için: 06:00 ile 22:00 Asia/Shanghai ve günde bir |
|
|
326
|
+
| `inTcccp` | promosyon rızası; varsayılan olarak kapalı olan 00:00-10:00 ve 21:00-24:00 bantları ayrı ayrı opt-in ister |
|
|
326
327
|
| `usTcpa` | kullanıcının yerel saatiyle 08:00 ile 21:00 (47 CFR 64.1200) |
|
|
327
328
|
| `euEprivacy` | pazarlama rızası, mevcut müşteriler için yumuşak opt-in |
|
|
328
329
|
| `telegramBot` | sohbet başına saniyede 1 ve dakikada 20 |
|
|
@@ -333,11 +334,15 @@ Her paket `sources` (sayıların geldiği sayfalar) ve neyi dışarıda bırakt
|
|
|
333
334
|
birbiriyle çelişir ve not hangi değerin neden seçildiğini söyler.
|
|
334
335
|
|
|
335
336
|
**Yasal bir pakete uzanmadan önce kapsamını okuyun.** Yukarıdaki bütün düzenlemeler *ticari*
|
|
336
|
-
iletişimi düzenler. `usTcpa`, `euEprivacy`, `krNetworkAct50` ve `
|
|
337
|
+
iletişimi düzenler. `usTcpa`, `euEprivacy`, `krNetworkAct50`, `jpAntiSpamLaw` ve `inTcccp` birer pazarlama
|
|
337
338
|
kuralıdır; yani mesajınızı ancak mesajın kendisi ticari olduğunda bağlar. Kullanıcının kendi
|
|
338
339
|
istediği bir hatırlatma reklam değildir ve onun için pazarlama paketi kullanmak, yasanın size
|
|
339
340
|
hiç koymadığı bir kısıtı kendi elinizle içeri almak olur. Aday promosyon niteliğindeyse
|
|
340
341
|
kullanın; değilse dürüst sınırlar platform kotaları ve kendi sessiz saatlerinizdir.
|
|
342
|
+
Alıcının kendi saat dilimini okuyan paketlerde (`usTcpa`, `krNetworkAct50`, `inTcccp`)
|
|
343
|
+
`user.timezone` yoksa karşılaştırılacak yerel saat de yoktur: o kontroller atlanır ve mesaj
|
|
344
|
+
çıkar. Her paketin notu bunu söyler; saat dilimi olmayan kullanıcı, bu paketlerden birine
|
|
345
|
+
güvenmeden önce çözülmesi gereken durumdur.
|
|
341
346
|
|
|
342
347
|
Bazı ülkelerin neden burada olmadığı da aynı kapsam sınavıyla açıklanır. Kanada'nın CASL'i ve
|
|
343
348
|
Avustralya'nın 2003 tarihli Spam Act'i rıza, gönderen kimliği ve abonelikten çıkma
|
|
@@ -347,8 +352,10 @@ telefonla pazarlama aramalarını kapsar. Hindistan ilginç olanı: sıkça tekr
|
|
|
347
352
|
birincil metinde yazmaz. TRAI düzenlemesi zaman bantlarını, içerik kategorisi ve gün tipiyle
|
|
348
353
|
birlikte, abonenin operatörüne *kaydettirdiği bir tercih* yapar; sabit bir yasal sessizlik
|
|
349
354
|
penceresi değildir. Üstelik pencereyi aktaran ikincil kaynaklar başlangıcın 09.00 mı 10.00 mı
|
|
350
|
-
olduğunda birbiriyle çelişir.
|
|
351
|
-
|
|
355
|
+
olduğunda birbiriyle çelişir. Düzenlemenin sabitlediği şey varsayılan durumdur:
|
|
356
|
+
Schedule-II'deki dokuz bandın dördü, yani 00:00-10:00 ve 21:00-24:00 arasını kapsayanlar,
|
|
357
|
+
abone o bandı açmadıkça her müşteri için kapalıdır. `inTcccp` bu bantları sabit bir pencere
|
|
358
|
+
yerine bant başına birer opt-in rızası olarak kodlar.
|
|
352
359
|
|
|
353
360
|
## Adaptörler
|
|
354
361
|
|
package/dist/src/checks.js
CHANGED
|
@@ -489,8 +489,10 @@ export function requiresConsent(options) {
|
|
|
489
489
|
const zone = zoneOf(ctx, when.timezone);
|
|
490
490
|
if (!zone)
|
|
491
491
|
return skip("no timezone on the user; consent window cannot be evaluated");
|
|
492
|
+
// A bare pass here would be indistinguishable from "the consent is on file",
|
|
493
|
+
// and explain() said exactly that about a consent nobody had given.
|
|
492
494
|
if (!inWindow(localClock(ctx.now, zone).minutes, when.start, when.end))
|
|
493
|
-
return pass;
|
|
495
|
+
return { kind: "pass", reason: `outside the consent window ${options.when.start} to ${options.when.end}` };
|
|
494
496
|
}
|
|
495
497
|
return ctx.user.consents?.[options.name] ? pass : reject(`consent "${options.name}" is missing${when ? ` (required ${options.when.start} to ${options.when.end})` : ""}`);
|
|
496
498
|
},
|
package/dist/src/explain.js
CHANGED
|
@@ -89,7 +89,11 @@ export const en = {
|
|
|
89
89
|
"allowedWindow.pass": (f) => (f.name ? `the "${f.name}" allowed window did not block it` : "the allowed window did not block it"),
|
|
90
90
|
"allowedWindow.reject": (f) => `messages may only go out between ${f.start} and ${f.end} (${f.zone}), and the local time was outside that window`,
|
|
91
91
|
"allowedWindow.skip": () => "the user has no time zone, so the allowed window could not be checked",
|
|
92
|
-
"requiresConsent.pass": (f) =>
|
|
92
|
+
"requiresConsent.pass": (f) => f.start
|
|
93
|
+
? `the ${f.name ? `"${f.name}" ` : ""}consent is only needed between ${f.start} and ${f.end}, and it was outside those hours`
|
|
94
|
+
: f.name
|
|
95
|
+
? `the "${f.name}" consent the check needs was in place`
|
|
96
|
+
: "the consent the check needs was in place",
|
|
93
97
|
"requiresConsent.reject": (f) => `the user has not given the required "${f.name}" consent${f.start ? `, which applies between ${f.start} and ${f.end}` : ""}`,
|
|
94
98
|
"requiresConsent.skip": () => "the user has no time zone, so the hours this consent applies could not be checked",
|
|
95
99
|
"recentInteraction.pass": () => "the user had written to the assistant recently enough",
|
|
@@ -163,6 +167,7 @@ const PARSERS = {
|
|
|
163
167
|
},
|
|
164
168
|
requiresConsent: {
|
|
165
169
|
stop: match(/^consent "(.+)" is missing(?: \(required (\S+) to (\S+)\))?$/, ["name", "start", "end"]),
|
|
170
|
+
passReason: match(/^outside the consent window (\S+) to (\S+)$/, ["start", "end"]),
|
|
166
171
|
idFacts: (id) => (id.startsWith("consent:") ? { name: id.slice("consent:".length) } : {}),
|
|
167
172
|
skip: fixed("no timezone on the user; consent window cannot be evaluated"),
|
|
168
173
|
},
|
|
@@ -245,8 +250,9 @@ function entryBody(entry, s) {
|
|
|
245
250
|
case "pass": {
|
|
246
251
|
if (reason) {
|
|
247
252
|
const facts = key ? PARSERS[key]?.passReason?.(reason) : BUDGET_NEAR(reason);
|
|
253
|
+
// The id carries facts a reason does not, like which consent this is.
|
|
248
254
|
if (facts)
|
|
249
|
-
return t(s, `${key ?? "budget"}.pass`, facts);
|
|
255
|
+
return t(s, `${key ?? "budget"}.pass`, { ...(key ? (PARSERS[key]?.idFacts?.(entry.id) ?? {}) : {}), ...facts });
|
|
250
256
|
return t(s, "fallback.pass", { id: entry.id, reason });
|
|
251
257
|
}
|
|
252
258
|
const facts = key ? (PARSERS[key]?.idFacts?.(entry.id) ?? {}) : {};
|
package/dist/src/presets.js
CHANGED
|
@@ -27,7 +27,7 @@ export const presets = {
|
|
|
27
27
|
wecomAppMessage: define(() => [c.rateLimit({ limit: 30, perSeconds: 60, id: "rate:30/min" }), c.rateLimit({ limit: 1000, perSeconds: 3600, id: "rate:1000/h" })], ["https://developer.work.weixin.qq.com/document/path/96212"], "WeCom application messages per app per member: 30 a minute and 1,000 an hour; the platform drops the excess silently, this preset refuses it with a reason."),
|
|
28
28
|
kakaoAlimtalk: define(() => [c.consent()], ["https://kakaobusiness.gitbook.io/main/ad/infotalk"], "AlimTalk is informational and carries no time-of-day limit; consent is the only gate."),
|
|
29
29
|
kakaoBrandMessage: define(() => [c.requiresConsent({ name: "ad" }), c.allowedWindow({ start: "08:00", end: "20:50", timezone: "Asia/Seoul", id: "window:kakao" })], ["https://kakaobusiness.gitbook.io/main/ad/moment/messagead/channelmessage/new/send"], "Brand messages need advertising consent and go out 08:00 to 20:50 Korea time regardless of the recipient's location. Official sources also quote 20:00 and 20:55; 20:50 is the stricter documented value."),
|
|
30
|
-
krNetworkAct50: define(() => [c.requiresConsent({ name: "ad" }), c.requiresConsent({ name: "night", when: { start: "21:00", end: "08:00", timezone: "user" } })], ["https://www.law.go.kr", "https://developers.fingerpush.com/biz-message/console/ads-guide"], "Network Act article 50: prior consent for advertising, and a separate consent for 21:00 to 08:00 (email is exempt). The two-year re-confirmation is not encoded."),
|
|
30
|
+
krNetworkAct50: define(() => [c.requiresConsent({ name: "ad" }), c.requiresConsent({ name: "night", when: { start: "21:00", end: "08:00", timezone: "user" } })], ["https://www.law.go.kr", "https://developers.fingerpush.com/biz-message/console/ads-guide"], "Network Act article 50: prior consent for advertising, and a separate consent for 21:00 to 08:00 (email is exempt). Without user.timezone the night consent cannot be placed in the day and skips; the advertising consent still applies. The two-year re-confirmation is not encoded."),
|
|
31
31
|
jpAntiSpamLaw: define(() => [c.requiresConsent({ name: "optIn" })], ["https://www.soumu.go.jp/main_sosiki/cybersecurity/kokumin/basic/legal/08/"], "Opt-in since 2008 with sender identity and an opt-out route. There is no time-of-day rule in the law; a Japanese quiet-hours window would be etiquette, so none is encoded."),
|
|
32
32
|
cnMinorMode: define(() => {
|
|
33
33
|
const window = c.allowedWindow({ start: "06:00", end: "22:00", timezone: "Asia/Shanghai", id: "window:minor" });
|
|
@@ -38,7 +38,21 @@ export const presets = {
|
|
|
38
38
|
{ id: budget.id, limit: budget.limit, run: (ctx) => (ctx.user.minor ? budget.run(ctx) : adult(ctx)), consume: (ctx) => (ctx.user.minor ? budget.consume(ctx) : Promise.resolve(true)) },
|
|
39
39
|
];
|
|
40
40
|
}, ["https://www.cac.gov.cn/2024-11/15/c_1733364304749288.htm", "https://www.cac.gov.cn/2022-01/04/c_1642894606364259.htm"], "Minor mode: no service 22:00 to 06:00 China time and a daily budget of one when user.minor is true; adults pass both checks. Per-age daily durations are not encoded."),
|
|
41
|
-
|
|
41
|
+
// India: TCCCPR 2018 (6 of 2018). Regulation 9 lets a commercial communication reach a
|
|
42
|
+
// recipient only per the recipient's registered preference or consent; Schedule-II item 3
|
|
43
|
+
// fixes nine time bands and keeps (i) 00:00-06:00, (ii) 06:00-08:00, (iii) 08:00-10:00
|
|
44
|
+
// and (ix) 21:00-24:00 default OFF for every customer until the subscriber switches that
|
|
45
|
+
// band on, which the four band flags below carry. The second source is the gazetted
|
|
46
|
+
// regulation and the third is the May 2026 consolidation, which says on its own first
|
|
47
|
+
// page that the gazetted text prevails where they differ. Read 2026-09-12.
|
|
48
|
+
inTcccp: define(() => [
|
|
49
|
+
c.requiresConsent({ name: "promotional" }),
|
|
50
|
+
c.requiresConsent({ name: "band00to06", when: { start: "00:00", end: "06:00", timezone: "user" } }),
|
|
51
|
+
c.requiresConsent({ name: "band06to08", when: { start: "06:00", end: "08:00", timezone: "user" } }),
|
|
52
|
+
c.requiresConsent({ name: "band08to10", when: { start: "08:00", end: "10:00", timezone: "user" } }),
|
|
53
|
+
c.requiresConsent({ name: "band21to24", when: { start: "21:00", end: "24:00", timezone: "user" } }),
|
|
54
|
+
], ["https://trai.gov.in/tcccpr", "https://www.trai.gov.in/sites/default/files/2025-01/RegulationUcc19072018.pdf", "https://www.trai.gov.in/sites/default/files/2026-05/CA_21052026.pdf"], "TCCCPR 2018: commercial communication needs the recipient's registered preference or consent (consents.promotional), and the Schedule-II default-off bands pass only when the subscriber opted that band in (the consents.band* flags, at the recipient's local time). The preference machinery is about promotional communication: the regulation's own block options exempt transactional and service communication and government communication (Schedule-II item 1 Note-4, item 3 Note-4), so pointing this preset at a transactional message imports a restriction the regulation does not place on it. Without user.timezone the four band checks skip and only the promotional consent is left. Opt-outs inside the default-on 10:00 to 21:00, day-type and per-category preferences are per-subscriber state a fixed check list cannot express; carry them in user.quietHours and consents. It binds SMS and voice calls on access networks, not in-app notifications or email; 1909 and DLT registration are out of scope. Sources read 2026-09-12."),
|
|
55
|
+
usTcpa: define(() => [c.allowedWindow({ start: "08:00", end: "21:00", timezone: "user", id: "window:tcpa" })], ["https://www.law.cornell.edu/cfr/text/47/64.1200"], "47 CFR 64.1200: no solicitation before 8 a.m. or after 9 p.m. at the called party's local time. Without user.timezone there is no local time to compare, so the check skips and the caller is not covered."),
|
|
42
56
|
euEprivacy: define(() => [{ ...c.requiresConsent({ name: "marketing" }), run: (ctx) => (ctx.user.existingCustomer ? { kind: "pass", reason: "existing customer, soft opt-in" } : c.requiresConsent({ name: "marketing" }).run(ctx)) }], ["https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32002L0058"], "Directive 2002/58/EC article 13: prior consent for direct marketing, with the soft opt-in for existing customers (user.existingCustomer)."),
|
|
43
57
|
telegramBot: define(() => [c.rateLimit({ limit: 1, perSeconds: 1, keyBy: "channel", id: "rate:1/s" }), c.rateLimit({ limit: 20, perSeconds: 60, keyBy: "channel", id: "rate:20/min" })], ["https://core.telegram.org/bots/faq"], "One message a second per chat and twenty a minute per group, keyed by candidate.channel. The broadcast rate of roughly thirty a second is not encoded."),
|
|
44
58
|
slackApp: define(() => [c.rateLimit({ limit: 1, perSeconds: 1, keyBy: "channel", id: "rate:1/s" })], ["https://docs.slack.dev/apis/web-api/rate-limits/"], "chat.postMessage: one message a second per channel, keyed by candidate.channel."),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proactive-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
package/spec/CONFORMANCE.md
CHANGED
|
@@ -11,9 +11,9 @@ The suite is versioned by `SPEC_VERSION`, and each version is tagged `spec/vX.Y.
|
|
|
11
11
|
separate from the package's own `vX.Y.Z` release tags.
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
git clone --depth 1 --branch spec/v1.
|
|
14
|
+
git clone --depth 1 --branch spec/v1.3.0 https://github.com/Bubblegunn/proactive-gate
|
|
15
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.
|
|
16
|
+
git subtree add --prefix spec https://github.com/Bubblegunn/proactive-gate spec/v1.3.0 --squash
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
A JavaScript implementation can also read the fixtures from an install, because the npm package
|
|
@@ -72,7 +72,9 @@ A fixture is a contract for every implementation, not only this one. So a change
|
|
|
72
72
|
`test/spec-lint.mjs` checks.
|
|
73
73
|
|
|
74
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.
|
|
75
|
+
adds a check or a field, a major changes an expectation. A new preset is a minor for the same
|
|
76
|
+
reason a new check is: 7.3 makes a policy that names an unknown preset a compile error, so nobody
|
|
77
|
+
already passes the fixture that exercises it.
|
|
76
78
|
|
|
77
79
|
## The honest status of this suite
|
|
78
80
|
|
package/spec/SPEC.md
CHANGED
|
@@ -7,6 +7,9 @@ empty at a stable release.
|
|
|
7
7
|
|
|
8
8
|
Versioning: patch releases add fixtures that existing implementations already pass; minor
|
|
9
9
|
releases add a check or field and mark it with `since`; major releases change an expectation.
|
|
10
|
+
A preset added to the vocabulary is a minor, because 7.3 requires an implementation to reject a
|
|
11
|
+
policy naming a preset it does not know, so a fixture that names the new preset is one no existing
|
|
12
|
+
implementation can pass.
|
|
10
13
|
An implementation declares the spec version it targets, and its CI MUST assert that the value
|
|
11
14
|
equals `SPEC_VERSION`.
|
|
12
15
|
|
package/spec/SPEC_VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.3.0
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"spec_version": "1.
|
|
2
|
+
"spec_version": "1.3.0",
|
|
3
3
|
"since": "1.0.0",
|
|
4
4
|
"name": "policy/unknown-check-is-an-error",
|
|
5
5
|
"description": "The runner never sees this fixture's expectation matter; it documents that compile errors are for the schema, not the runtime. A minimal valid policy passes.",
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
{
|
|
2
|
+
"spec_version": "1.3.0",
|
|
3
|
+
"since": "1.3.0",
|
|
4
|
+
"name": "presets/in-tcccp",
|
|
5
|
+
"description": "inTcccp needs promotional consent always, and each Schedule-II default-off band passes only when the subscriber opted that band in.",
|
|
6
|
+
"policy": {
|
|
7
|
+
"specVersion": "1.0.0",
|
|
8
|
+
"checks": [
|
|
9
|
+
{
|
|
10
|
+
"preset": "inTcccp"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"tests": [
|
|
15
|
+
{
|
|
16
|
+
"description": "no promotional consent",
|
|
17
|
+
"input": {
|
|
18
|
+
"user": {
|
|
19
|
+
"id": "u1",
|
|
20
|
+
"consent": true,
|
|
21
|
+
"proactiveEnabled": true,
|
|
22
|
+
"mode": "normal",
|
|
23
|
+
"intensity": "normal",
|
|
24
|
+
"timezone": "Asia/Kolkata",
|
|
25
|
+
"quietHours": {
|
|
26
|
+
"start": "22:00",
|
|
27
|
+
"end": "08:00"
|
|
28
|
+
},
|
|
29
|
+
"createdAt": "2026-01-01T00:00:00Z",
|
|
30
|
+
"consents": {}
|
|
31
|
+
},
|
|
32
|
+
"candidate": {
|
|
33
|
+
"id": "c1",
|
|
34
|
+
"type": "reminder",
|
|
35
|
+
"priority": "normal",
|
|
36
|
+
"surfaces": [
|
|
37
|
+
"push",
|
|
38
|
+
"feed"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"now": "2026-09-04T06:30:00Z"
|
|
42
|
+
},
|
|
43
|
+
"expect": {
|
|
44
|
+
"allowed": false,
|
|
45
|
+
"rejectedBy": "consent:promotional",
|
|
46
|
+
"trace": [
|
|
47
|
+
"consent:promotional"
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"description": "promotional consent at 12:00 IST passes",
|
|
53
|
+
"input": {
|
|
54
|
+
"user": {
|
|
55
|
+
"id": "u1",
|
|
56
|
+
"consent": true,
|
|
57
|
+
"proactiveEnabled": true,
|
|
58
|
+
"mode": "normal",
|
|
59
|
+
"intensity": "normal",
|
|
60
|
+
"timezone": "Asia/Kolkata",
|
|
61
|
+
"quietHours": {
|
|
62
|
+
"start": "22:00",
|
|
63
|
+
"end": "08:00"
|
|
64
|
+
},
|
|
65
|
+
"createdAt": "2026-01-01T00:00:00Z",
|
|
66
|
+
"consents": {
|
|
67
|
+
"promotional": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"candidate": {
|
|
71
|
+
"id": "c1",
|
|
72
|
+
"type": "reminder",
|
|
73
|
+
"priority": "normal",
|
|
74
|
+
"surfaces": [
|
|
75
|
+
"push",
|
|
76
|
+
"feed"
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
"now": "2026-09-04T06:30:00Z"
|
|
80
|
+
},
|
|
81
|
+
"expect": {
|
|
82
|
+
"allowed": true,
|
|
83
|
+
"trace": [
|
|
84
|
+
"consent:promotional",
|
|
85
|
+
"consent:band00to06",
|
|
86
|
+
"consent:band06to08",
|
|
87
|
+
"consent:band08to10",
|
|
88
|
+
"consent:band21to24"
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"description": "22:30 IST without the band ix opt-in rejects",
|
|
94
|
+
"input": {
|
|
95
|
+
"user": {
|
|
96
|
+
"id": "u1",
|
|
97
|
+
"consent": true,
|
|
98
|
+
"proactiveEnabled": true,
|
|
99
|
+
"mode": "normal",
|
|
100
|
+
"intensity": "normal",
|
|
101
|
+
"timezone": "Asia/Kolkata",
|
|
102
|
+
"quietHours": {
|
|
103
|
+
"start": "22:00",
|
|
104
|
+
"end": "08:00"
|
|
105
|
+
},
|
|
106
|
+
"createdAt": "2026-01-01T00:00:00Z",
|
|
107
|
+
"consents": {
|
|
108
|
+
"promotional": true
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"candidate": {
|
|
112
|
+
"id": "c1",
|
|
113
|
+
"type": "reminder",
|
|
114
|
+
"priority": "normal",
|
|
115
|
+
"surfaces": [
|
|
116
|
+
"push",
|
|
117
|
+
"feed"
|
|
118
|
+
]
|
|
119
|
+
},
|
|
120
|
+
"now": "2026-09-04T17:00:00Z"
|
|
121
|
+
},
|
|
122
|
+
"expect": {
|
|
123
|
+
"allowed": false,
|
|
124
|
+
"rejectedBy": "consent:band21to24",
|
|
125
|
+
"trace": [
|
|
126
|
+
"consent:promotional",
|
|
127
|
+
"consent:band00to06",
|
|
128
|
+
"consent:band06to08",
|
|
129
|
+
"consent:band08to10",
|
|
130
|
+
"consent:band21to24"
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"description": "22:30 IST with the band ix opt-in passes",
|
|
136
|
+
"input": {
|
|
137
|
+
"user": {
|
|
138
|
+
"id": "u1",
|
|
139
|
+
"consent": true,
|
|
140
|
+
"proactiveEnabled": true,
|
|
141
|
+
"mode": "normal",
|
|
142
|
+
"intensity": "normal",
|
|
143
|
+
"timezone": "Asia/Kolkata",
|
|
144
|
+
"quietHours": {
|
|
145
|
+
"start": "22:00",
|
|
146
|
+
"end": "08:00"
|
|
147
|
+
},
|
|
148
|
+
"createdAt": "2026-01-01T00:00:00Z",
|
|
149
|
+
"consents": {
|
|
150
|
+
"promotional": true,
|
|
151
|
+
"band21to24": true
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"candidate": {
|
|
155
|
+
"id": "c1",
|
|
156
|
+
"type": "reminder",
|
|
157
|
+
"priority": "normal",
|
|
158
|
+
"surfaces": [
|
|
159
|
+
"push",
|
|
160
|
+
"feed"
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
"now": "2026-09-04T17:00:00Z"
|
|
164
|
+
},
|
|
165
|
+
"expect": {
|
|
166
|
+
"allowed": true,
|
|
167
|
+
"trace": [
|
|
168
|
+
"consent:promotional",
|
|
169
|
+
"consent:band00to06",
|
|
170
|
+
"consent:band06to08",
|
|
171
|
+
"consent:band08to10",
|
|
172
|
+
"consent:band21to24"
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"description": "04:00 IST rejects when only band 06:00 to 08:00 was opted in",
|
|
178
|
+
"input": {
|
|
179
|
+
"user": {
|
|
180
|
+
"id": "u1",
|
|
181
|
+
"consent": true,
|
|
182
|
+
"proactiveEnabled": true,
|
|
183
|
+
"mode": "normal",
|
|
184
|
+
"intensity": "normal",
|
|
185
|
+
"timezone": "Asia/Kolkata",
|
|
186
|
+
"quietHours": {
|
|
187
|
+
"start": "22:00",
|
|
188
|
+
"end": "08:00"
|
|
189
|
+
},
|
|
190
|
+
"createdAt": "2026-01-01T00:00:00Z",
|
|
191
|
+
"consents": {
|
|
192
|
+
"promotional": true,
|
|
193
|
+
"band06to08": true
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
"candidate": {
|
|
197
|
+
"id": "c1",
|
|
198
|
+
"type": "reminder",
|
|
199
|
+
"priority": "normal",
|
|
200
|
+
"surfaces": [
|
|
201
|
+
"push",
|
|
202
|
+
"feed"
|
|
203
|
+
]
|
|
204
|
+
},
|
|
205
|
+
"now": "2026-09-04T22:30:00Z"
|
|
206
|
+
},
|
|
207
|
+
"expect": {
|
|
208
|
+
"allowed": false,
|
|
209
|
+
"rejectedBy": "consent:band00to06",
|
|
210
|
+
"trace": [
|
|
211
|
+
"consent:promotional",
|
|
212
|
+
"consent:band00to06"
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"description": "09:00 IST without the band iii opt-in rejects",
|
|
218
|
+
"input": {
|
|
219
|
+
"user": {
|
|
220
|
+
"id": "u1",
|
|
221
|
+
"consent": true,
|
|
222
|
+
"proactiveEnabled": true,
|
|
223
|
+
"mode": "normal",
|
|
224
|
+
"intensity": "normal",
|
|
225
|
+
"timezone": "Asia/Kolkata",
|
|
226
|
+
"quietHours": {
|
|
227
|
+
"start": "22:00",
|
|
228
|
+
"end": "08:00"
|
|
229
|
+
},
|
|
230
|
+
"createdAt": "2026-01-01T00:00:00Z",
|
|
231
|
+
"consents": {
|
|
232
|
+
"promotional": true
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
"candidate": {
|
|
236
|
+
"id": "c1",
|
|
237
|
+
"type": "reminder",
|
|
238
|
+
"priority": "normal",
|
|
239
|
+
"surfaces": [
|
|
240
|
+
"push",
|
|
241
|
+
"feed"
|
|
242
|
+
]
|
|
243
|
+
},
|
|
244
|
+
"now": "2026-09-04T03:30:00Z"
|
|
245
|
+
},
|
|
246
|
+
"expect": {
|
|
247
|
+
"allowed": false,
|
|
248
|
+
"rejectedBy": "consent:band08to10",
|
|
249
|
+
"trace": [
|
|
250
|
+
"consent:promotional",
|
|
251
|
+
"consent:band00to06",
|
|
252
|
+
"consent:band06to08",
|
|
253
|
+
"consent:band08to10"
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
]
|
|
258
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"spec_version": "1.
|
|
2
|
+
"spec_version": "1.3.0",
|
|
3
3
|
"since": "1.1.0",
|
|
4
4
|
"name": "quiet-hours/caller-supplied-dates",
|
|
5
5
|
"description": "A calendar date the caller supplies beats the weekday and the default, in both directions. There is no bundled holiday calendar.",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"spec_version": "1.
|
|
2
|
+
"spec_version": "1.3.0",
|
|
3
3
|
"since": "1.1.0",
|
|
4
4
|
"name": "quiet-hours/crosses-midnight-by-day",
|
|
5
5
|
"description": "A window belongs to the day it opens on, so Friday 18:00 to 02:00 silences Saturday morning. Saturday has no window of its own, so the carry is the only thing that can explain the silence.",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"spec_version": "1.
|
|
2
|
+
"spec_version": "1.3.0",
|
|
3
3
|
"since": "1.1.0",
|
|
4
4
|
"name": "quiet-hours/weekday-schedule",
|
|
5
5
|
"description": "A Friday and Saturday weekend in Asia/Dubai (UTC+4, no daylight saving). A date beats a weekday beats the default, and null means the day has no quiet hours.",
|