proactive-gate 0.4.1 → 0.6.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 +83 -14
- package/README.tr.md +55 -3
- package/dist/src/checks.js +3 -1
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +92 -1
- package/dist/src/demo-week.d.ts +38 -0
- package/dist/src/demo-week.js +115 -0
- package/dist/src/explain.js +8 -2
- package/dist/src/presets.js +16 -2
- package/dist/src/simulate-report.d.ts +27 -0
- package/dist/src/simulate-report.js +128 -0
- package/dist/src/simulate.d.ts +118 -0
- package/dist/src/simulate.js +269 -0
- package/package.json +4 -3
- package/spec/CONFORMANCE.md +49 -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
|
@@ -36,6 +36,67 @@ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
|
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
## What it changes, before you install anything
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
npx proactive-gate simulate
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
No key, no account, nothing to configure. It replays one week of an assistant that fires when its
|
|
46
|
+
own data arrives rather than when the recipient is awake, first with no gate and then through the
|
|
47
|
+
default order, and prints what each one did with every candidate.
|
|
48
|
+
|
|
49
|
+
| | no gate | proactive-gate |
|
|
50
|
+
| ----------------------------------------------------------- | ------: | -------------: |
|
|
51
|
+
| delivered | 171 | 74 |
|
|
52
|
+
| held | 0 | 97 |
|
|
53
|
+
| **delivered inside the recipient's own quiet hours** | **52** | **3** |
|
|
54
|
+
| of those, critical, which the documented floor lets through | 5 | 3 |
|
|
55
|
+
| most one person received in one local day | 6 | 5 |
|
|
56
|
+
|
|
57
|
+
The row in bold is the one worth reading twice. It counts deliveries inside the window each person
|
|
58
|
+
set for themselves, not inside a curfew somebody picked for them: the user in the week who asked for
|
|
59
|
+
no quiet hours at all is not protected from herself, and the one whose window runs 00:00 to 06:00 is
|
|
60
|
+
not judged against anyone else's night. Under the default order the only three that landed there were
|
|
61
|
+
critical, which is what the documented priority floor is for.
|
|
62
|
+
|
|
63
|
+
**Scope and method, because a number without them is decoration.** The week is a generator plus a
|
|
64
|
+
seed, not anybody's traffic: eight users in five time zones, candidate instants drawn uniformly
|
|
65
|
+
across the UTC day, parameters written down in `src/demo-week.ts` and dumped to
|
|
66
|
+
[`examples/week.jsonl`](examples/week.jsonl) so you can read what it produced. It measures what a
|
|
67
|
+
policy does to a stream. It cannot tell you whether a message was wanted, or what its recipient did
|
|
68
|
+
with it. Every user in it has consented and no dismissals are seeded, so `consent`, `enabled`,
|
|
69
|
+
`killSwitch`, `dedupe` and `dismissalCooldown` never fire in that run.
|
|
70
|
+
|
|
71
|
+
Three more ways to run it:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
npx proactive-gate simulate --disagreements --why # the sentence behind each hold
|
|
75
|
+
npx proactive-gate simulate your-candidates.jsonl # your traffic, not a generated week
|
|
76
|
+
npx proactive-gate simulate --policy examples/policies/aggressive.json \
|
|
77
|
+
--policy examples/policies/respectful.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The last one is the comparison to run before changing a policy in production: same stream, two
|
|
81
|
+
policies, and the 73 candidates they disagree about listed one by one.
|
|
82
|
+
|
|
83
|
+
## What this will not do
|
|
84
|
+
|
|
85
|
+
This repository is young and grew quickly, so the boundary is written down rather than left to be
|
|
86
|
+
inferred from what happens to be in it.
|
|
87
|
+
|
|
88
|
+
- **The twelve checks are the policy surface.** A thirteenth needs a deployment whose rule the
|
|
89
|
+
twelve cannot express, not a rule that exists somewhere in the world.
|
|
90
|
+
- **The preset catalogue is frozen at what is merged.** A new preset needs someone who is shipping
|
|
91
|
+
to that channel, or under that instrument, and who says so on the issue. "This country also has a
|
|
92
|
+
law" is not the bar, because every country does, and a preset nobody ships against rots unread.
|
|
93
|
+
- **Adapters and stores are frozen on the same terms.** The next one arrives with the person who
|
|
94
|
+
needs it.
|
|
95
|
+
- **What grows instead is evidence**: the simulator above, the conformance suite, and an
|
|
96
|
+
implementation of the specification by somebody who is not us.
|
|
97
|
+
- **What will never be here**: anything that needs a server, a hosted account, or reads the content
|
|
98
|
+
of a message.
|
|
99
|
+
|
|
39
100
|
Or start from a policy file and the wiring for your framework, in one command:
|
|
40
101
|
|
|
41
102
|
```
|
|
@@ -44,7 +105,7 @@ npx proactive-gate init --preset usTcpa --framework mastra
|
|
|
44
105
|
|
|
45
106
|
That writes `proactive-gate.policy.json` with the ten checks in order, appends the
|
|
46
107
|
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
|
|
108
|
+
the gate into that framework. `npx proactive-gate init --list` shows the fifteen
|
|
48
109
|
platform and legal presets and the four frameworks.
|
|
49
110
|
|
|
50
111
|
If you would rather see the argument than read it: `npm run bench:compare` replays a
|
|
@@ -361,6 +422,7 @@ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessag
|
|
|
361
422
|
| `krNetworkAct50` | advertising consent, plus night consent for 21:00 to 08:00 local |
|
|
362
423
|
| `jpAntiSpamLaw` | opt-in |
|
|
363
424
|
| `cnMinorMode` | for minors: 06:00 to 22:00 Asia/Shanghai and one a day |
|
|
425
|
+
| `inTcccp` | promotional consent; the default-off bands 00:00 to 10:00 and 21:00 to 24:00 need their own opt-in |
|
|
364
426
|
| `usTcpa` | 08:00 to 21:00 at the user's local time (47 CFR 64.1200) |
|
|
365
427
|
| `euEprivacy` | marketing consent with the soft opt-in for existing customers |
|
|
366
428
|
| `telegramBot` | 1 a second and 20 a minute per chat |
|
|
@@ -371,23 +433,30 @@ out. Reviewable defaults, not legal advice: several official sources disagree wi
|
|
|
371
433
|
and the note says which value was chosen and why.
|
|
372
434
|
|
|
373
435
|
**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
|
|
436
|
+
*commercial* communication. `usTcpa`, `euEprivacy`, `krNetworkAct50`, `jpAntiSpamLaw` and
|
|
437
|
+
`inTcccp` are marketing rules, so they bind your message only when the message itself is
|
|
438
|
+
commercial. A reminder your user asked for is not advertising, and pulling in a marketing preset for it
|
|
377
439
|
imports a restriction the law never placed on you, which is its own kind of wrong answer.
|
|
378
440
|
Use them when the candidate is promotional; when it is not, the platform quotas and your own
|
|
379
|
-
quiet hours are the honest constraints.
|
|
441
|
+
quiet hours are the honest constraints. A preset that reads the recipient's own zone
|
|
442
|
+
(`usTcpa`, `krNetworkAct50`, `inTcccp`) has no local time to compare without `user.timezone`,
|
|
443
|
+
so those checks skip and the message goes out; each preset's note says so, and a user with no
|
|
444
|
+
zone is the case to handle before you rely on one of them.
|
|
380
445
|
|
|
381
446
|
That scope test is also why some jurisdictions people ask for are missing. Canada's CASL and
|
|
382
447
|
Australia's Spam Act 2003 set consent, identification and unsubscribe duties, and neither
|
|
383
448
|
carries a time-of-day rule at all. The Brazilian window quoted around the web comes from bill
|
|
384
449
|
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
|
|
450
|
+
the interesting one, and the widely repeated "9am to 9pm" is not what the primary text says.
|
|
451
|
+
The Telecom Commercial Communications Customer Preference Regulations make time bands a
|
|
387
452
|
*preference the subscriber registers* with their access provider, alongside content category
|
|
388
453
|
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
|
-
|
|
454
|
+
window disagree with each other about whether it starts at 09:00 or 10:00. What the
|
|
455
|
+
regulation does fix is the default: four of the nine Schedule-II bands, covering 00:00 to
|
|
456
|
+
10:00 and 21:00 to 24:00, are off for every customer until the subscriber switches that band
|
|
457
|
+
on, so `inTcccp` encodes those as one opt-in consent per band rather than a hard window. It was
|
|
458
|
+
contributed by [@LouisDeconinck](https://github.com/LouisDeconinck) in
|
|
459
|
+
[#29](https://github.com/Bubblegunn/proactive-gate/pull/29), in TypeScript and Python together.
|
|
391
460
|
|
|
392
461
|
## The budget is enforced at commit, not at evaluate
|
|
393
462
|
|
|
@@ -653,7 +722,7 @@ reproduces exactly. The race property was checked against a mutant: rewriting
|
|
|
653
722
|
[`spec/SPEC.md`](spec/SPEC.md) states the behaviour as numbered requirements, and
|
|
654
723
|
[`spec/fixtures`](spec/fixtures) holds language-neutral cases: the DST edge in
|
|
655
724
|
America/New_York, Pacific/Apia, a wall-clock case in 2031, atomic commit, the ISO week,
|
|
656
|
-
deferral, shadow mode, the optional checks and
|
|
725
|
+
deferral, shadow mode, the optional checks and six presets. The TypeScript tests and the
|
|
657
726
|
Python tests both run all of them; `npx proactive-gate replay --fixtures spec/fixtures` runs
|
|
658
727
|
them from the command line. A third implementation starts from the fixtures, not from this
|
|
659
728
|
source.
|
|
@@ -664,7 +733,7 @@ package's release tags, so an implementation in any language can pin it without
|
|
|
664
733
|
or PyPI:
|
|
665
734
|
|
|
666
735
|
```sh
|
|
667
|
-
git clone --depth 1 --branch spec/v1.
|
|
736
|
+
git clone --depth 1 --branch spec/v1.3.0 https://github.com/Bubblegunn/proactive-gate
|
|
668
737
|
```
|
|
669
738
|
|
|
670
739
|
The npm package also ships it, so `node_modules/proactive-gate/spec/fixtures` exists after an
|
|
@@ -677,8 +746,8 @@ Generated by `npm run conformance-table`; CI fails when it is stale.
|
|
|
677
746
|
|
|
678
747
|
| implementation | spec version | fixtures passed | declared skips |
|
|
679
748
|
|---|---|---:|---|
|
|
680
|
-
| TypeScript | 1.
|
|
681
|
-
| Python | 1.
|
|
749
|
+
| TypeScript | 1.3.0 | 33 of 33 | none |
|
|
750
|
+
| Python | 1.3.0 | 33 of 33 | none |
|
|
682
751
|
<!-- conformance:end -->
|
|
683
752
|
|
|
684
753
|
### What made this work elsewhere, and why it might not here
|
|
@@ -785,7 +854,7 @@ before. [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) wrote `SqliteStor
|
|
|
785
854
|
every release since, including the one you install today. @Aaqibhafeezkhan came back for a
|
|
786
855
|
second one and wrote the store contract suite in [#24](https://github.com/Bubblegunn/proactive-gate/pull/24).
|
|
787
856
|
|
|
788
|
-
A third person arrived from the other direction. [@LouisDeconinck](https://github.com/LouisDeconinck) took [#26](https://github.com/Bubblegunn/proactive-gate/issues/26), an issue this project filed against itself to admit that expired rows were never cleaned up, and twelve minutes later sent the fix in both languages with the test that would have caught the original bug ([#27](https://github.com/Bubblegunn/proactive-gate/pull/27), in 0.3.1). He then took [#23](https://github.com/Bubblegunn/proactive-gate/issues/23), the issue asking for a rejection reason a product manager could read, and wrote its sixty-seven sentence templates in both languages ([#28](https://github.com/Bubblegunn/proactive-gate/pull/28), in 0.4.0). That is the work in this library that is hardest to review and easiest to get wrong, because it lives in the wording rather than in the code.
|
|
857
|
+
A third person arrived from the other direction. [@LouisDeconinck](https://github.com/LouisDeconinck) took [#26](https://github.com/Bubblegunn/proactive-gate/issues/26), an issue this project filed against itself to admit that expired rows were never cleaned up, and twelve minutes later sent the fix in both languages with the test that would have caught the original bug ([#27](https://github.com/Bubblegunn/proactive-gate/pull/27), in 0.3.1). He then took [#23](https://github.com/Bubblegunn/proactive-gate/issues/23), the issue asking for a rejection reason a product manager could read, and wrote its sixty-seven sentence templates in both languages ([#28](https://github.com/Bubblegunn/proactive-gate/pull/28), in 0.4.0). That is the work in this library that is hardest to review and easiest to get wrong, because it lives in the wording rather than in the code. His third, hours later, was the India preset ([#29](https://github.com/Bubblegunn/proactive-gate/pull/29), in 0.5.0): he read TRAI's Schedule-II rather than the summaries everyone repeats, found that the "9am to 9pm" everybody quotes is not in the primary text, and encoded the four default-off bands as the opt-ins the regulation actually describes.
|
|
789
858
|
|
|
790
859
|
## Cite this
|
|
791
860
|
|
package/README.tr.md
CHANGED
|
@@ -29,6 +29,51 @@ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
|
|
|
29
29
|
}
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
## Kurmadan önce ne değiştirdiğini görün
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
npx proactive-gate simulate
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Anahtar yok, hesap yok, ayar yok. Verisi hazır olduğunda tetiklenen, yani kullanıcının uyanık olup
|
|
39
|
+
olmadığına bakmayan bir asistanın bir haftasını önce hiç geçit olmadan, sonra varsayılan sırayla
|
|
40
|
+
tekrar oynatır ve her adaya ne olduğunu yazar.
|
|
41
|
+
|
|
42
|
+
| | geçit yok | proactive-gate |
|
|
43
|
+
| ------------------------------------------------------ | --------: | -------------: |
|
|
44
|
+
| iletildi | 171 | 74 |
|
|
45
|
+
| tutuldu | 0 | 97 |
|
|
46
|
+
| **kişinin kendi sessiz saatleri içinde iletilen** | **52** | **3** |
|
|
47
|
+
| bunlardan kritik olanlar (eşiğin geçirdiği) | 5 | 3 |
|
|
48
|
+
| bir kişinin bir günde aldığı en yüksek sayı | 6 | 5 |
|
|
49
|
+
|
|
50
|
+
Koyu satır iki kez okunmaya değer: herkesin **kendi** belirlediği pencereyi sayar, birinin başkası
|
|
51
|
+
için seçtiği bir sokağa çıkma yasağını değil. Varsayılan sırada o pencereye giren üç mesajın üçü de
|
|
52
|
+
kritikti, ki belgelenmiş öncelik eşiği tam bunun için var.
|
|
53
|
+
|
|
54
|
+
**Kapsam ve yöntem, çünkü bunlar olmadan sayı süstür.** Hafta bir üretici ve bir tohumdur, kimsenin
|
|
55
|
+
gerçek trafiği değil: beş saat diliminde sekiz kullanıcı, adayların anları UTC günü boyunca düzgün
|
|
56
|
+
dağılımla seçilmiş, bütün parametreler `src/demo-week.ts` içinde yazılı ve
|
|
57
|
+
[`examples/week.jsonl`](examples/week.jsonl) dosyasına dökülmüş. Bir politikanın bir akışa ne
|
|
58
|
+
yaptığını ölçer; bir mesajın istenip istendiğini ya da alıcının onunla ne yaptığını ölçemez.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
npx proactive-gate simulate --disagreements --why # her tutmanın arkasındaki cümle
|
|
62
|
+
npx proactive-gate simulate kendi-adaylarim.jsonl # üretilmiş hafta değil, sizin trafiğiniz
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Bu kütüphanenin yapmayacağı şeyler
|
|
66
|
+
|
|
67
|
+
- **On iki kontrol, politika yüzeyinin tamamıdır.** On üçüncüsü için, on ikisinin ifade edemediği
|
|
68
|
+
bir kuralı olan gerçek bir kurulum gerekir.
|
|
69
|
+
- **Preset kataloğu birleşmiş hâliyle donduruldu.** Yeni bir preset için, o kanala ya da o mevzuata
|
|
70
|
+
göre gerçekten ürün gönderen ve bunu issue'da söyleyen birine ihtiyaç var. "Bu ülkenin de bir
|
|
71
|
+
yasası var" yeterli değil, çünkü her ülkenin var; kimsenin kullanmadığı preset çürür.
|
|
72
|
+
- **Adaptörler ve store'lar da aynı kuralla dondu.** Bir sonraki, ona ihtiyacı olan kişiyle gelir.
|
|
73
|
+
- **Bunun yerine büyüyen şey kanıt**: yukarıdaki simülatör, uygunluk paketi ve spesifikasyonun
|
|
74
|
+
bizim dışımızda biri tarafından yazılmış bir uygulaması.
|
|
75
|
+
- **Asla olmayacaklar**: sunucu, barındırılan hesap ya da mesaj içeriğini okuyan herhangi bir şey.
|
|
76
|
+
|
|
32
77
|
Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağımsız: kapı, "model bir
|
|
33
78
|
şey üretti" ile "kullanıcının telefonu titredi" arasında durur; hangi model ya da framework
|
|
34
79
|
üretmiş olursa olsun. Örnekler: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
|
|
@@ -323,6 +368,7 @@ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessag
|
|
|
323
368
|
| `krNetworkAct50` | reklam rızası, ayrıca 21:00 ile 08:00 yerel saat için gece rızası |
|
|
324
369
|
| `jpAntiSpamLaw` | opt-in |
|
|
325
370
|
| `cnMinorMode` | reşit olmayanlar için: 06:00 ile 22:00 Asia/Shanghai ve günde bir |
|
|
371
|
+
| `inTcccp` | promosyon rızası; varsayılan olarak kapalı olan 00:00-10:00 ve 21:00-24:00 bantları ayrı ayrı opt-in ister ([@LouisDeconinck](https://github.com/LouisDeconinck), [#29](https://github.com/Bubblegunn/proactive-gate/pull/29)) |
|
|
326
372
|
| `usTcpa` | kullanıcının yerel saatiyle 08:00 ile 21:00 (47 CFR 64.1200) |
|
|
327
373
|
| `euEprivacy` | pazarlama rızası, mevcut müşteriler için yumuşak opt-in |
|
|
328
374
|
| `telegramBot` | sohbet başına saniyede 1 ve dakikada 20 |
|
|
@@ -333,11 +379,15 @@ Her paket `sources` (sayıların geldiği sayfalar) ve neyi dışarıda bırakt
|
|
|
333
379
|
birbiriyle çelişir ve not hangi değerin neden seçildiğini söyler.
|
|
334
380
|
|
|
335
381
|
**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 `
|
|
382
|
+
iletişimi düzenler. `usTcpa`, `euEprivacy`, `krNetworkAct50`, `jpAntiSpamLaw` ve `inTcccp` birer pazarlama
|
|
337
383
|
kuralıdır; yani mesajınızı ancak mesajın kendisi ticari olduğunda bağlar. Kullanıcının kendi
|
|
338
384
|
istediği bir hatırlatma reklam değildir ve onun için pazarlama paketi kullanmak, yasanın size
|
|
339
385
|
hiç koymadığı bir kısıtı kendi elinizle içeri almak olur. Aday promosyon niteliğindeyse
|
|
340
386
|
kullanın; değilse dürüst sınırlar platform kotaları ve kendi sessiz saatlerinizdir.
|
|
387
|
+
Alıcının kendi saat dilimini okuyan paketlerde (`usTcpa`, `krNetworkAct50`, `inTcccp`)
|
|
388
|
+
`user.timezone` yoksa karşılaştırılacak yerel saat de yoktur: o kontroller atlanır ve mesaj
|
|
389
|
+
çıkar. Her paketin notu bunu söyler; saat dilimi olmayan kullanıcı, bu paketlerden birine
|
|
390
|
+
güvenmeden önce çözülmesi gereken durumdur.
|
|
341
391
|
|
|
342
392
|
Bazı ülkelerin neden burada olmadığı da aynı kapsam sınavıyla açıklanır. Kanada'nın CASL'i ve
|
|
343
393
|
Avustralya'nın 2003 tarihli Spam Act'i rıza, gönderen kimliği ve abonelikten çıkma
|
|
@@ -347,8 +397,10 @@ telefonla pazarlama aramalarını kapsar. Hindistan ilginç olanı: sıkça tekr
|
|
|
347
397
|
birincil metinde yazmaz. TRAI düzenlemesi zaman bantlarını, içerik kategorisi ve gün tipiyle
|
|
348
398
|
birlikte, abonenin operatörüne *kaydettirdiği bir tercih* yapar; sabit bir yasal sessizlik
|
|
349
399
|
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
|
-
|
|
400
|
+
olduğunda birbiriyle çelişir. Düzenlemenin sabitlediği şey varsayılan durumdur:
|
|
401
|
+
Schedule-II'deki dokuz bandın dördü, yani 00:00-10:00 ve 21:00-24:00 arasını kapsayanlar,
|
|
402
|
+
abone o bandı açmadıkça her müşteri için kapalıdır. `inTcccp` bu bantları sabit bir pencere
|
|
403
|
+
yerine bant başına birer opt-in rızası olarak kodlar.
|
|
352
404
|
|
|
353
405
|
## Adaptörler
|
|
354
406
|
|
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/cli.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { Decision, EvaluateInput } from "./types.js";
|
|
|
4
4
|
export declare function loadPolicy(path?: string): Promise<Gate>;
|
|
5
5
|
export declare function replay(lines: string[], gate: Gate, commit: boolean): Promise<Decision[]>;
|
|
6
6
|
export declare function summarize(decisions: Decision[]): string;
|
|
7
|
+
/** One JSONL line per candidate, the same shape `replay` reads. */
|
|
8
|
+
export declare function parseEvents(text: string): EvaluateInput[];
|
|
7
9
|
interface PreToolUseEvent {
|
|
8
10
|
tool_name?: string;
|
|
9
11
|
tool_input?: {
|
package/dist/src/cli.js
CHANGED
|
@@ -21,12 +21,33 @@ import { createRequire } from "node:module";
|
|
|
21
21
|
import { createGate } from "./gate.js";
|
|
22
22
|
import { defaultChecks } from "./checks.js";
|
|
23
23
|
import { loadFixtures, readSkips, runFixture } from "./conformance.js";
|
|
24
|
+
import { simulate } from "./simulate.js";
|
|
25
|
+
import { formatSimulation } from "./simulate-report.js";
|
|
26
|
+
import { DEMO_WEEK_NOTE, demoWeek, demoWeekPeople } from "./demo-week.js";
|
|
24
27
|
import { FRAMEWORKS, listText, plan } from "./init.js";
|
|
25
|
-
const HELP = `usage: proactive-gate
|
|
28
|
+
const HELP = `usage: proactive-gate simulate [events.jsonl] [--policy <file>]... [--seed <n>]
|
|
29
|
+
proactive-gate init [--preset <name>] [--framework <name>] [--out <file>]
|
|
26
30
|
proactive-gate replay <events.jsonl> [--policy <file>] [--json] [--commit]
|
|
27
31
|
proactive-gate replay --fixtures <dir> [--skip <file>]
|
|
28
32
|
proactive-gate hook --policy <file> [--tool <name>]
|
|
29
33
|
|
|
34
|
+
simulate runs one stream of candidates under two policies and shows what each one did
|
|
35
|
+
with every candidate: sent, held and why, deferred and until when, which budget it spent.
|
|
36
|
+
With no arguments it runs a generated week against no gate at all, which is the comparison
|
|
37
|
+
that answers what installing this would change. It adds nothing to the decision path: the
|
|
38
|
+
same gate runs twice.
|
|
39
|
+
|
|
40
|
+
events.jsonl your own candidates, one EvaluateInput per line (default: a generated week)
|
|
41
|
+
--policy <file> a policy.json to run. Given once, it is compared against no gate; given
|
|
42
|
+
twice, the two policies are compared against each other
|
|
43
|
+
--seed <n> seeds the generated week and the simulated transport (default 7)
|
|
44
|
+
--limit <n> timeline rows to print, 0 for all (default 20)
|
|
45
|
+
--disagreements print only the candidates the policies disagreed about
|
|
46
|
+
--why print each held candidate's sentence under its row
|
|
47
|
+
--transport-failure-rate <f> fraction of simulated sends that fail (default 0)
|
|
48
|
+
--dump-events <f> write the generated week to <f> as JSONL and exit
|
|
49
|
+
--json the whole result, for computing your own figures
|
|
50
|
+
|
|
30
51
|
init writes a policy you can read and edit, and prints the lines that wire it in.
|
|
31
52
|
|
|
32
53
|
--preset <name> a platform or legal preset to append (see --list)
|
|
@@ -125,6 +146,30 @@ export function summarize(decisions) {
|
|
|
125
146
|
return lines.join("\n");
|
|
126
147
|
}
|
|
127
148
|
const pct = (n, total) => (total ? `${((100 * n) / total).toFixed(1)}%` : "0%");
|
|
149
|
+
/** Every value of a flag that may be repeated, in the order it was given. */
|
|
150
|
+
const argValues = (argv, flag) => {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const [i, a] of argv.entries())
|
|
153
|
+
if (a === flag && argv[i + 1] !== undefined)
|
|
154
|
+
out.push(argv[i + 1]);
|
|
155
|
+
return out;
|
|
156
|
+
};
|
|
157
|
+
/** One JSONL line per candidate, the same shape `replay` reads. */
|
|
158
|
+
export function parseEvents(text) {
|
|
159
|
+
const events = [];
|
|
160
|
+
for (const [i, line] of text.split("\n").entries()) {
|
|
161
|
+
if (!line.trim())
|
|
162
|
+
continue;
|
|
163
|
+
try {
|
|
164
|
+
const raw = JSON.parse(line);
|
|
165
|
+
events.push({ ...raw, ...(raw.now ? { now: new Date(raw.now) } : {}) });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
throw new Error(`line ${i + 1}: ${error instanceof Error ? error.message : String(error)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return events;
|
|
172
|
+
}
|
|
128
173
|
const argValue = (argv, flag) => {
|
|
129
174
|
const i = argv.indexOf(flag);
|
|
130
175
|
return i >= 0 ? argv[i + 1] : undefined;
|
|
@@ -189,6 +234,52 @@ async function main(argv) {
|
|
|
189
234
|
return;
|
|
190
235
|
}
|
|
191
236
|
const [command, file] = argv;
|
|
237
|
+
if (command === "simulate") {
|
|
238
|
+
const seed = Number(argValue(argv, "--seed") ?? 7);
|
|
239
|
+
if (!Number.isFinite(seed)) {
|
|
240
|
+
console.error("--seed takes a number");
|
|
241
|
+
process.exit(2);
|
|
242
|
+
}
|
|
243
|
+
const eventsFile = file && !file.startsWith("--") ? file : undefined;
|
|
244
|
+
const events = eventsFile ? parseEvents(await readFile(eventsFile, "utf8")) : demoWeek(seed);
|
|
245
|
+
const dump = argValue(argv, "--dump-events");
|
|
246
|
+
if (dump) {
|
|
247
|
+
const lines = events.map((e) => JSON.stringify({ user: e.user, candidate: e.candidate, now: e.now?.toISOString() }));
|
|
248
|
+
await writeFile(dump, `${lines.join("\n")}\n`);
|
|
249
|
+
console.log(`wrote ${events.length} events to ${dump}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const policyFiles = argValues(argv, "--policy");
|
|
253
|
+
const policies = [];
|
|
254
|
+
for (const path of policyFiles) {
|
|
255
|
+
if (!path.endsWith(".json")) {
|
|
256
|
+
console.error(`simulate reads policy documents, not modules: ${path} must be a .json policy, because the simulation owns the store it reads budgets back out of`);
|
|
257
|
+
process.exit(2);
|
|
258
|
+
}
|
|
259
|
+
const label = path.replace(/^.*[/\\]/, "").replace(/\.json$/, "");
|
|
260
|
+
policies.push({ label, policy: JSON.parse(await readFile(path, "utf8")) });
|
|
261
|
+
}
|
|
262
|
+
// No policy given: the comparison a stranger wants, no gate against the default order.
|
|
263
|
+
// One policy given: the same comparison, against theirs. Two or more: theirs against theirs.
|
|
264
|
+
if (policies.length === 0)
|
|
265
|
+
policies.push({ label: "proactive-gate", checks: defaultChecks() });
|
|
266
|
+
if (policies.length === 1)
|
|
267
|
+
policies.unshift({ label: "no gate" });
|
|
268
|
+
const failureRate = Number(argValue(argv, "--transport-failure-rate") ?? 0);
|
|
269
|
+
const result = await simulate({ events, policies, seed, transportFailureRate: failureRate });
|
|
270
|
+
if (argv.includes("--json")) {
|
|
271
|
+
console.log(JSON.stringify(result, null, 2));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const limitArg = argValue(argv, "--limit");
|
|
275
|
+
console.log(formatSimulation(result, {
|
|
276
|
+
...(limitArg === undefined ? {} : { limit: Number(limitArg) }),
|
|
277
|
+
disagreementsOnly: argv.includes("--disagreements"),
|
|
278
|
+
why: argv.includes("--why"),
|
|
279
|
+
...(eventsFile ? {} : { note: DEMO_WEEK_NOTE, people: demoWeekPeople() }),
|
|
280
|
+
}));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
192
283
|
if (command === "init") {
|
|
193
284
|
if (argv.includes("--list")) {
|
|
194
285
|
console.log(listText());
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The week `proactive-gate simulate` runs when you give it nothing of your own.
|
|
3
|
+
*
|
|
4
|
+
* It is a generator plus a seed, not a committed blob, so it is auditable in a way a data file
|
|
5
|
+
* is not: the parameters below are the whole definition, and `--dump-events` writes out exactly
|
|
6
|
+
* what they produced. It adds nothing to the published package.
|
|
7
|
+
*
|
|
8
|
+
* What it is: eight users in five time zones over seven days, with an assistant that fires when
|
|
9
|
+
* its own data arrives rather than when the recipient is awake. That is the ordinary failure
|
|
10
|
+
* mode this library exists for, and it is why candidate instants are drawn uniformly across the
|
|
11
|
+
* UTC day rather than placed politely inside each user's waking hours.
|
|
12
|
+
*
|
|
13
|
+
* What it is not: anybody's real traffic, and not a measurement of one. It cannot tell you how
|
|
14
|
+
* often a real assistant has something to say, whether a message was wanted, or what a user did
|
|
15
|
+
* with it. It measures what a policy does to a stream, which is the only thing a policy decides.
|
|
16
|
+
*
|
|
17
|
+
* What it does not reach, said plainly so nobody reads a clean run as full coverage: every user
|
|
18
|
+
* here has consented and is enabled, so `consent`, `enabled` and `killSwitch` never fire; no
|
|
19
|
+
* dismissals are seeded, so `dismissalCooldown` never fires; no candidate carries a `dedupeKey`,
|
|
20
|
+
* `pAccept` or `busy`, so `dedupe`, `utilityFloor` and `boundedDeferral` stay quiet; and no
|
|
21
|
+
* preset is involved. The checks it does exercise are quiet hours with its priority floor,
|
|
22
|
+
* mode, mute, snooze, intensity, the trust ramp and the daily budget.
|
|
23
|
+
*/
|
|
24
|
+
import type { EvaluateInput } from "./types.js";
|
|
25
|
+
/** First and last instant of the generated week, inclusive of the first, exclusive of the last. */
|
|
26
|
+
export declare const DEMO_WEEK_START = "2026-09-07T00:00:00.000Z";
|
|
27
|
+
export declare const DEMO_WEEK_DAYS = 7;
|
|
28
|
+
/**
|
|
29
|
+
* The generated week, in instant order.
|
|
30
|
+
*
|
|
31
|
+
* Ordered by instant rather than grouped by user because that is the order a server produces
|
|
32
|
+
* them in, and because the budget and the deferral queue both depend on the order they arrive.
|
|
33
|
+
*/
|
|
34
|
+
export declare function demoWeek(seed?: number): EvaluateInput[];
|
|
35
|
+
/** One line per person, for the footer of the table, so the week is legible without reading code. */
|
|
36
|
+
export declare const demoWeekPeople: () => string[];
|
|
37
|
+
/** What the week is and is not, printed under any table built from it. */
|
|
38
|
+
export declare const DEMO_WEEK_NOTE: string;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** First and last instant of the generated week, inclusive of the first, exclusive of the last. */
|
|
2
|
+
export const DEMO_WEEK_START = "2026-09-07T00:00:00.000Z";
|
|
3
|
+
export const DEMO_WEEK_DAYS = 7;
|
|
4
|
+
/** One line per user, and every field on it is there to make a particular check reachable. */
|
|
5
|
+
const PEOPLE = [
|
|
6
|
+
{
|
|
7
|
+
user: { id: "ayse", proactiveEnabled: true, mode: "normal", intensity: "normal", timezone: "Europe/Istanbul", quietHours: { start: "22:00", end: "08:00" }, createdAt: "2026-04-02T00:00:00Z" },
|
|
8
|
+
why: "the ordinary case: an established account with ordinary quiet hours",
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
user: { id: "ben", proactiveEnabled: true, mode: "normal", intensity: "low", timezone: "America/New_York", quietHours: { start: "23:00", end: "07:00" }, createdAt: "2026-01-15T00:00:00Z" },
|
|
12
|
+
why: "intensity low, so the floor rises and ordinary messages stop being worth sending",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
user: { id: "chika", proactiveEnabled: true, mode: "normal", intensity: "normal", timezone: "Asia/Tokyo", quietHours: { start: "22:30", end: "06:30" }, createdAt: "2026-09-04T00:00:00Z" },
|
|
16
|
+
why: "three days old at the start of the week, so the trust ramp is still on",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
user: { id: "dilan", proactiveEnabled: true, mode: "normal", intensity: "normal", timezone: "Europe/Istanbul", quietHours: { start: "22:00", end: "08:00" }, mutedTypes: ["digest"], createdAt: "2026-03-01T00:00:00Z" },
|
|
20
|
+
why: "one type muted, which is a user's own decision and not a budget",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
user: { id: "emre", proactiveEnabled: true, mode: "normal", intensity: "normal", timezone: "America/Sao_Paulo", quietHours: { start: "23:00", end: "07:00" }, snoozedUntil: "2026-09-10T12:00:00Z", createdAt: "2026-02-20T00:00:00Z" },
|
|
24
|
+
why: "snoozed into the middle of the week, so the first days are held rather than dropped",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
user: { id: "fatima", proactiveEnabled: true, mode: "focus", intensity: "normal", timezone: "Europe/London", quietHours: { start: "22:00", end: "07:30" }, createdAt: "2025-11-11T00:00:00Z" },
|
|
28
|
+
why: "in focus mode all week, which the default order only lets critical through",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
user: { id: "gabriel", proactiveEnabled: true, mode: "normal", intensity: "normal", timezone: "America/New_York", quietHours: { start: "00:00", end: "06:00" }, createdAt: "2026-09-06T00:00:00Z" },
|
|
32
|
+
why: "one day old, with a narrow night: a new account is the strictest case there is",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
user: { id: "hana", proactiveEnabled: true, mode: "normal", intensity: "high", timezone: "Asia/Tokyo", quietHours: null, createdAt: "2025-08-01T00:00:00Z" },
|
|
36
|
+
why: "intensity high and no quiet hours at all: the person who wants everything",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
/** Types an assistant of this shape actually produces, and nothing invented for the demo. */
|
|
40
|
+
const TYPES = ["reminder", "insight", "digest", "alert", "nudge"];
|
|
41
|
+
/** Priorities, weighted the way a real stream is: mostly ordinary, rarely an emergency. */
|
|
42
|
+
const PRIORITIES = [
|
|
43
|
+
{ value: "low", weight: 15 },
|
|
44
|
+
{ value: "normal", weight: 60 },
|
|
45
|
+
{ value: "high", weight: 20 },
|
|
46
|
+
{ value: "critical", weight: 5 },
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* Candidates per user per day, drawn uniformly from this list: a mean of about three, which is
|
|
50
|
+
* a product that surfaces a couple of reminders, a digest and the occasional alert. Chosen
|
|
51
|
+
* before the run rather than after seeing it, and wide enough that the default daily budget of
|
|
52
|
+
* five binds for the chattier days instead of never being reached.
|
|
53
|
+
*/
|
|
54
|
+
const PER_DAY = [1, 2, 3, 3, 4, 6];
|
|
55
|
+
function mulberry32(seed) {
|
|
56
|
+
let a = seed >>> 0;
|
|
57
|
+
return () => {
|
|
58
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
59
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
60
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
61
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const pick = (random, values) => values[Math.floor(random() * values.length)];
|
|
65
|
+
function pickPriority(random) {
|
|
66
|
+
const total = PRIORITIES.reduce((n, p) => n + p.weight, 0);
|
|
67
|
+
let roll = random() * total;
|
|
68
|
+
for (const p of PRIORITIES) {
|
|
69
|
+
roll -= p.weight;
|
|
70
|
+
if (roll <= 0)
|
|
71
|
+
return p.value;
|
|
72
|
+
}
|
|
73
|
+
return "normal";
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The generated week, in instant order.
|
|
77
|
+
*
|
|
78
|
+
* Ordered by instant rather than grouped by user because that is the order a server produces
|
|
79
|
+
* them in, and because the budget and the deferral queue both depend on the order they arrive.
|
|
80
|
+
*/
|
|
81
|
+
export function demoWeek(seed = 7) {
|
|
82
|
+
const random = mulberry32(seed);
|
|
83
|
+
const start = Date.parse(DEMO_WEEK_START);
|
|
84
|
+
const events = [];
|
|
85
|
+
let n = 0;
|
|
86
|
+
for (const { user } of PEOPLE) {
|
|
87
|
+
for (let day = 0; day < DEMO_WEEK_DAYS; day += 1) {
|
|
88
|
+
const count = pick(random, PER_DAY);
|
|
89
|
+
for (let i = 0; i < count; i += 1) {
|
|
90
|
+
const minuteOfDay = Math.floor(random() * 24 * 60);
|
|
91
|
+
const at = new Date(start + day * 86400000 + minuteOfDay * 60000);
|
|
92
|
+
const candidate = {
|
|
93
|
+
id: `c${(n += 1)}`,
|
|
94
|
+
type: pick(random, TYPES),
|
|
95
|
+
priority: pickPriority(random),
|
|
96
|
+
surfaces: ["push", "feed"],
|
|
97
|
+
};
|
|
98
|
+
events.push({ user: { ...user, consent: true }, candidate, now: at });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return events.sort((a, b) => (a.now?.getTime() ?? 0) - (b.now?.getTime() ?? 0));
|
|
103
|
+
}
|
|
104
|
+
/** One line per person, for the footer of the table, so the week is legible without reading code. */
|
|
105
|
+
export const demoWeekPeople = () => PEOPLE.map(({ user, why }) => `${user.id} (${user.timezone}): ${why}`);
|
|
106
|
+
/** What the week is and is not, printed under any table built from it. */
|
|
107
|
+
export const DEMO_WEEK_NOTE = [
|
|
108
|
+
"This is a generated week, not anybody's traffic: eight users in five time zones over seven days,",
|
|
109
|
+
"with candidate instants drawn uniformly across the UTC day, which is how an assistant that fires",
|
|
110
|
+
"when its data arrives meets a person who is asleep. It measures what a policy does to a stream.",
|
|
111
|
+
"It cannot tell you whether a message was wanted, or what its recipient did with it.",
|
|
112
|
+
"Every user here has consented and no dismissals are seeded, so consent, enabled, killSwitch,",
|
|
113
|
+
"dedupe and dismissalCooldown never fire in this run. Point --events at your own JSONL for a",
|
|
114
|
+
"number about your own traffic.",
|
|
115
|
+
].join("\n");
|
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."),
|