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.
Files changed (50) hide show
  1. package/README.md +83 -14
  2. package/README.tr.md +55 -3
  3. package/dist/src/checks.js +3 -1
  4. package/dist/src/cli.d.ts +2 -0
  5. package/dist/src/cli.js +92 -1
  6. package/dist/src/demo-week.d.ts +38 -0
  7. package/dist/src/demo-week.js +115 -0
  8. package/dist/src/explain.js +8 -2
  9. package/dist/src/presets.js +16 -2
  10. package/dist/src/simulate-report.d.ts +27 -0
  11. package/dist/src/simulate-report.js +128 -0
  12. package/dist/src/simulate.d.ts +118 -0
  13. package/dist/src/simulate.js +269 -0
  14. package/package.json +4 -3
  15. package/spec/CONFORMANCE.md +49 -3
  16. package/spec/SPEC.md +3 -0
  17. package/spec/SPEC_VERSION +1 -1
  18. package/spec/fixtures/adaptive-timing/placeholder.json +1 -1
  19. package/spec/fixtures/budget/bypass-priority.json +1 -1
  20. package/spec/fixtures/budget/daily-atomic-commit.json +1 -1
  21. package/spec/fixtures/budget/near-limit.json +1 -1
  22. package/spec/fixtures/budget/race-second-commit-loses.json +1 -1
  23. package/spec/fixtures/budget/weekly-iso-week.json +1 -1
  24. package/spec/fixtures/consent/required.json +1 -1
  25. package/spec/fixtures/cooldown/three-dismissals.json +1 -1
  26. package/spec/fixtures/dedupe/already-delivered.json +1 -1
  27. package/spec/fixtures/dedupe/no-key-skips.json +1 -1
  28. package/spec/fixtures/defer/snooze-as-defer.json +1 -1
  29. package/spec/fixtures/mode/allow-list.json +1 -1
  30. package/spec/fixtures/ordering/kill-switch.json +1 -1
  31. package/spec/fixtures/ordering/short-circuit.json +1 -1
  32. package/spec/fixtures/policy/unknown-check-is-an-error.json +1 -1
  33. package/spec/fixtures/presets/cn-minor-mode.json +1 -1
  34. package/spec/fixtures/presets/in-tcccp.json +258 -0
  35. package/spec/fixtures/presets/kakao-brand-message.json +1 -1
  36. package/spec/fixtures/presets/kr-network-act-50.json +1 -1
  37. package/spec/fixtures/presets/telegram-bot.json +1 -1
  38. package/spec/fixtures/presets/us-tcpa.json +1 -1
  39. package/spec/fixtures/quiet-hours/apia.json +1 -1
  40. package/spec/fixtures/quiet-hours/caller-supplied-dates.json +1 -1
  41. package/spec/fixtures/quiet-hours/crosses-midnight-by-day.json +1 -1
  42. package/spec/fixtures/quiet-hours/dst-new-york.json +1 -1
  43. package/spec/fixtures/quiet-hours/istanbul.json +1 -1
  44. package/spec/fixtures/quiet-hours/wall-clock.json +1 -1
  45. package/spec/fixtures/quiet-hours/weekday-schedule.json +1 -1
  46. package/spec/fixtures/shadow/reject-continues.json +1 -1
  47. package/spec/fixtures/trust-ramp/first-week.json +1 -1
  48. package/spec/fixtures/utility/bounded-deferral-cap.json +1 -1
  49. package/spec/fixtures/utility/bounded-deferral.json +1 -1
  50. 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 fourteen
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` and `jpAntiSpamLaw` are
375
- marketing rules, so they bind your message only when the message itself is commercial. A
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: the widely repeated "9am to 9pm" is not what the primary text says. The
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. A preset built on
390
- that would encode a number no primary source states, so there is none.
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 four presets. The TypeScript tests and the
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.2.0 https://github.com/Bubblegunn/proactive-gate
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.2.0 | 32 of 32 | none |
681
- | Python | 1.2.0 | 32 of 32 | none |
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 `jpAntiSpamLaw` birer pazarlama
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. Bunun üzerine kurulacak bir paket, hiçbir birincil kaynağın
351
- yazmadığı bir sayıyı kodlardı; o yüzden yok.
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
 
@@ -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 init [--preset <name>] [--framework <name>] [--out <file>]
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");
@@ -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) => (f.name ? `the "${f.name}" consent the check needs was in place` : "the consent the check needs was in place"),
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) ?? {}) : {};
@@ -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
- 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."),
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."),