proactive-gate 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +19 -13
  2. package/README.tr.md +10 -3
  3. package/dist/src/checks.js +3 -1
  4. package/dist/src/explain.js +8 -2
  5. package/dist/src/presets.js +16 -2
  6. package/package.json +1 -1
  7. package/spec/CONFORMANCE.md +5 -3
  8. package/spec/SPEC.md +3 -0
  9. package/spec/SPEC_VERSION +1 -1
  10. package/spec/fixtures/adaptive-timing/placeholder.json +1 -1
  11. package/spec/fixtures/budget/bypass-priority.json +1 -1
  12. package/spec/fixtures/budget/daily-atomic-commit.json +1 -1
  13. package/spec/fixtures/budget/near-limit.json +1 -1
  14. package/spec/fixtures/budget/race-second-commit-loses.json +1 -1
  15. package/spec/fixtures/budget/weekly-iso-week.json +1 -1
  16. package/spec/fixtures/consent/required.json +1 -1
  17. package/spec/fixtures/cooldown/three-dismissals.json +1 -1
  18. package/spec/fixtures/dedupe/already-delivered.json +1 -1
  19. package/spec/fixtures/dedupe/no-key-skips.json +1 -1
  20. package/spec/fixtures/defer/snooze-as-defer.json +1 -1
  21. package/spec/fixtures/mode/allow-list.json +1 -1
  22. package/spec/fixtures/ordering/kill-switch.json +1 -1
  23. package/spec/fixtures/ordering/short-circuit.json +1 -1
  24. package/spec/fixtures/policy/unknown-check-is-an-error.json +1 -1
  25. package/spec/fixtures/presets/cn-minor-mode.json +1 -1
  26. package/spec/fixtures/presets/in-tcccp.json +258 -0
  27. package/spec/fixtures/presets/kakao-brand-message.json +1 -1
  28. package/spec/fixtures/presets/kr-network-act-50.json +1 -1
  29. package/spec/fixtures/presets/telegram-bot.json +1 -1
  30. package/spec/fixtures/presets/us-tcpa.json +1 -1
  31. package/spec/fixtures/quiet-hours/apia.json +1 -1
  32. package/spec/fixtures/quiet-hours/caller-supplied-dates.json +1 -1
  33. package/spec/fixtures/quiet-hours/crosses-midnight-by-day.json +1 -1
  34. package/spec/fixtures/quiet-hours/dst-new-york.json +1 -1
  35. package/spec/fixtures/quiet-hours/istanbul.json +1 -1
  36. package/spec/fixtures/quiet-hours/wall-clock.json +1 -1
  37. package/spec/fixtures/quiet-hours/weekday-schedule.json +1 -1
  38. package/spec/fixtures/shadow/reject-continues.json +1 -1
  39. package/spec/fixtures/trust-ramp/first-week.json +1 -1
  40. package/spec/fixtures/utility/bounded-deferral-cap.json +1 -1
  41. package/spec/fixtures/utility/bounded-deferral.json +1 -1
  42. package/spec/fixtures/utility/floor.json +1 -1
package/README.md CHANGED
@@ -44,7 +44,7 @@ npx proactive-gate init --preset usTcpa --framework mastra
44
44
 
45
45
  That writes `proactive-gate.policy.json` with the ten checks in order, appends the
46
46
  preset you named, and prints the preset's own source next to the few lines that plug
47
- the gate into that framework. `npx proactive-gate init --list` shows the fourteen
47
+ the gate into that framework. `npx proactive-gate init --list` shows the fifteen
48
48
  platform and legal presets and the four frameworks.
49
49
 
50
50
  If you would rather see the argument than read it: `npm run bench:compare` replays a
@@ -361,6 +361,7 @@ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessag
361
361
  | `krNetworkAct50` | advertising consent, plus night consent for 21:00 to 08:00 local |
362
362
  | `jpAntiSpamLaw` | opt-in |
363
363
  | `cnMinorMode` | for minors: 06:00 to 22:00 Asia/Shanghai and one a day |
364
+ | `inTcccp` | promotional consent; the default-off bands 00:00 to 10:00 and 21:00 to 24:00 need their own opt-in |
364
365
  | `usTcpa` | 08:00 to 21:00 at the user's local time (47 CFR 64.1200) |
365
366
  | `euEprivacy` | marketing consent with the soft opt-in for existing customers |
366
367
  | `telegramBot` | 1 a second and 20 a minute per chat |
@@ -371,23 +372,28 @@ out. Reviewable defaults, not legal advice: several official sources disagree wi
371
372
  and the note says which value was chosen and why.
372
373
 
373
374
  **Read the scope before you reach for a legal preset.** Every instrument above regulates
374
- *commercial* communication. `usTcpa`, `euEprivacy`, `krNetworkAct50` 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
375
+ *commercial* communication. `usTcpa`, `euEprivacy`, `krNetworkAct50`, `jpAntiSpamLaw` and
376
+ `inTcccp` are marketing rules, so they bind your message only when the message itself is
377
+ commercial. A reminder your user asked for is not advertising, and pulling in a marketing preset for it
377
378
  imports a restriction the law never placed on you, which is its own kind of wrong answer.
378
379
  Use them when the candidate is promotional; when it is not, the platform quotas and your own
379
- quiet hours are the honest constraints.
380
+ quiet hours are the honest constraints. A preset that reads the recipient's own zone
381
+ (`usTcpa`, `krNetworkAct50`, `inTcccp`) has no local time to compare without `user.timezone`,
382
+ so those checks skip and the message goes out; each preset's note says so, and a user with no
383
+ zone is the case to handle before you rely on one of them.
380
384
 
381
385
  That scope test is also why some jurisdictions people ask for are missing. Canada's CASL and
382
386
  Australia's Spam Act 2003 set consent, identification and unsubscribe duties, and neither
383
387
  carries a time-of-day rule at all. The Brazilian window quoted around the web comes from bill
384
388
  PLS 48/2018, a proposal rather than enacted law, and it covers telemarketing calls. India is
385
- the interesting one: 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
389
+ the interesting one, and the widely repeated "9am to 9pm" is not what the primary text says.
390
+ The Telecom Commercial Communications Customer Preference Regulations make time bands a
387
391
  *preference the subscriber registers* with their access provider, alongside content category
388
392
  and day type, not a fixed statutory quiet window, and the secondary sources that quote a
389
- window disagree with each other about whether it starts at 09:00 or 10:00. A preset built on
390
- that would encode a number no primary source states, so there is none.
393
+ window disagree with each other about whether it starts at 09:00 or 10:00. What the
394
+ regulation does fix is the default: four of the nine Schedule-II bands, covering 00:00 to
395
+ 10:00 and 21:00 to 24:00, are off for every customer until the subscriber switches that band
396
+ on, so `inTcccp` encodes those as one opt-in consent per band rather than a hard window.
391
397
 
392
398
  ## The budget is enforced at commit, not at evaluate
393
399
 
@@ -653,7 +659,7 @@ reproduces exactly. The race property was checked against a mutant: rewriting
653
659
  [`spec/SPEC.md`](spec/SPEC.md) states the behaviour as numbered requirements, and
654
660
  [`spec/fixtures`](spec/fixtures) holds language-neutral cases: the DST edge in
655
661
  America/New_York, Pacific/Apia, a wall-clock case in 2031, atomic commit, the ISO week,
656
- deferral, shadow mode, the optional checks and four presets. The TypeScript tests and the
662
+ deferral, shadow mode, the optional checks and six presets. The TypeScript tests and the
657
663
  Python tests both run all of them; `npx proactive-gate replay --fixtures spec/fixtures` runs
658
664
  them from the command line. A third implementation starts from the fixtures, not from this
659
665
  source.
@@ -664,7 +670,7 @@ package's release tags, so an implementation in any language can pin it without
664
670
  or PyPI:
665
671
 
666
672
  ```sh
667
- git clone --depth 1 --branch spec/v1.2.0 https://github.com/Bubblegunn/proactive-gate
673
+ git clone --depth 1 --branch spec/v1.3.0 https://github.com/Bubblegunn/proactive-gate
668
674
  ```
669
675
 
670
676
  The npm package also ships it, so `node_modules/proactive-gate/spec/fixtures` exists after an
@@ -677,8 +683,8 @@ Generated by `npm run conformance-table`; CI fails when it is stale.
677
683
 
678
684
  | implementation | spec version | fixtures passed | declared skips |
679
685
  |---|---|---:|---|
680
- | TypeScript | 1.2.0 | 32 of 32 | none |
681
- | Python | 1.2.0 | 32 of 32 | none |
686
+ | TypeScript | 1.3.0 | 33 of 33 | none |
687
+ | Python | 1.3.0 | 33 of 33 | none |
682
688
  <!-- conformance:end -->
683
689
 
684
690
  ### What made this work elsewhere, and why it might not here
package/README.tr.md CHANGED
@@ -323,6 +323,7 @@ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessag
323
323
  | `krNetworkAct50` | reklam rızası, ayrıca 21:00 ile 08:00 yerel saat için gece rızası |
324
324
  | `jpAntiSpamLaw` | opt-in |
325
325
  | `cnMinorMode` | reşit olmayanlar için: 06:00 ile 22:00 Asia/Shanghai ve günde bir |
326
+ | `inTcccp` | promosyon rızası; varsayılan olarak kapalı olan 00:00-10:00 ve 21:00-24:00 bantları ayrı ayrı opt-in ister |
326
327
  | `usTcpa` | kullanıcının yerel saatiyle 08:00 ile 21:00 (47 CFR 64.1200) |
327
328
  | `euEprivacy` | pazarlama rızası, mevcut müşteriler için yumuşak opt-in |
328
329
  | `telegramBot` | sohbet başına saniyede 1 ve dakikada 20 |
@@ -333,11 +334,15 @@ Her paket `sources` (sayıların geldiği sayfalar) ve neyi dışarıda bırakt
333
334
  birbiriyle çelişir ve not hangi değerin neden seçildiğini söyler.
334
335
 
335
336
  **Yasal bir pakete uzanmadan önce kapsamını okuyun.** Yukarıdaki bütün düzenlemeler *ticari*
336
- iletişimi düzenler. `usTcpa`, `euEprivacy`, `krNetworkAct50` ve `jpAntiSpamLaw` birer pazarlama
337
+ iletişimi düzenler. `usTcpa`, `euEprivacy`, `krNetworkAct50`, `jpAntiSpamLaw` ve `inTcccp` birer pazarlama
337
338
  kuralıdır; yani mesajınızı ancak mesajın kendisi ticari olduğunda bağlar. Kullanıcının kendi
338
339
  istediği bir hatırlatma reklam değildir ve onun için pazarlama paketi kullanmak, yasanın size
339
340
  hiç koymadığı bir kısıtı kendi elinizle içeri almak olur. Aday promosyon niteliğindeyse
340
341
  kullanın; değilse dürüst sınırlar platform kotaları ve kendi sessiz saatlerinizdir.
342
+ Alıcının kendi saat dilimini okuyan paketlerde (`usTcpa`, `krNetworkAct50`, `inTcccp`)
343
+ `user.timezone` yoksa karşılaştırılacak yerel saat de yoktur: o kontroller atlanır ve mesaj
344
+ çıkar. Her paketin notu bunu söyler; saat dilimi olmayan kullanıcı, bu paketlerden birine
345
+ güvenmeden önce çözülmesi gereken durumdur.
341
346
 
342
347
  Bazı ülkelerin neden burada olmadığı da aynı kapsam sınavıyla açıklanır. Kanada'nın CASL'i ve
343
348
  Avustralya'nın 2003 tarihli Spam Act'i rıza, gönderen kimliği ve abonelikten çıkma
@@ -347,8 +352,10 @@ telefonla pazarlama aramalarını kapsar. Hindistan ilginç olanı: sıkça tekr
347
352
  birincil metinde yazmaz. TRAI düzenlemesi zaman bantlarını, içerik kategorisi ve gün tipiyle
348
353
  birlikte, abonenin operatörüne *kaydettirdiği bir tercih* yapar; sabit bir yasal sessizlik
349
354
  penceresi değildir. Üstelik pencereyi aktaran ikincil kaynaklar başlangıcın 09.00 mı 10.00 mı
350
- olduğunda birbiriyle çelişir. Bunun üzerine kurulacak bir paket, hiçbir birincil kaynağın
351
- yazmadığı bir sayıyı kodlardı; o yüzden yok.
355
+ olduğunda birbiriyle çelişir. Düzenlemenin sabitlediği şey varsayılan durumdur:
356
+ Schedule-II'deki dokuz bandın dördü, yani 00:00-10:00 ve 21:00-24:00 arasını kapsayanlar,
357
+ abone o bandı açmadıkça her müşteri için kapalıdır. `inTcccp` bu bantları sabit bir pencere
358
+ yerine bant başına birer opt-in rızası olarak kodlar.
352
359
 
353
360
  ## Adaptörler
354
361
 
@@ -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
  },
@@ -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."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks as code or JSON, a conformance spec, presets for platform and legal limits, adapters for AI SDK, Mastra, LangChain and OpenAI Agents, and a Python sibling.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -11,9 +11,9 @@ The suite is versioned by `SPEC_VERSION`, and each version is tagged `spec/vX.Y.
11
11
  separate from the package's own `vX.Y.Z` release tags.
12
12
 
13
13
  ```sh
14
- git clone --depth 1 --branch spec/v1.2.0 https://github.com/Bubblegunn/proactive-gate
14
+ git clone --depth 1 --branch spec/v1.3.0 https://github.com/Bubblegunn/proactive-gate
15
15
  # or, to keep it beside your own source and update it deliberately
16
- git subtree add --prefix spec https://github.com/Bubblegunn/proactive-gate spec/v1.2.0 --squash
16
+ git subtree add --prefix spec https://github.com/Bubblegunn/proactive-gate spec/v1.3.0 --squash
17
17
  ```
18
18
 
19
19
  A JavaScript implementation can also read the fixtures from an install, because the npm package
@@ -72,7 +72,9 @@ A fixture is a contract for every implementation, not only this one. So a change
72
72
  `test/spec-lint.mjs` checks.
73
73
 
74
74
  Versioning follows `SPEC.md`: a patch adds fixtures existing implementations already pass, a minor
75
- adds a check or a field, a major changes an expectation.
75
+ adds a check or a field, a major changes an expectation. A new preset is a minor for the same
76
+ reason a new check is: 7.3 makes a policy that names an unknown preset a compile error, so nobody
77
+ already passes the fixture that exercises it.
76
78
 
77
79
  ## The honest status of this suite
78
80
 
package/spec/SPEC.md CHANGED
@@ -7,6 +7,9 @@ empty at a stable release.
7
7
 
8
8
  Versioning: patch releases add fixtures that existing implementations already pass; minor
9
9
  releases add a check or field and mark it with `since`; major releases change an expectation.
10
+ A preset added to the vocabulary is a minor, because 7.3 requires an implementation to reject a
11
+ policy naming a preset it does not know, so a fixture that names the new preset is one no existing
12
+ implementation can pass.
10
13
  An implementation declares the spec version it targets, and its CI MUST assert that the value
11
14
  equals `SPEC_VERSION`.
12
15
 
package/spec/SPEC_VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.3.0
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "adaptive-timing/placeholder",
5
5
  "description": "A JSON policy cannot carry a next-good-moment function; the placeholder passes and keeps the trace shape.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "budget/bypass-priority",
5
5
  "description": "A bypass priority passes the budget without reading or consuming it.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "budget/daily-atomic-commit",
5
5
  "description": "evaluate reads the counter, commit consumes it, the extra commit is refused.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "budget/near-limit",
5
5
  "description": "A pass close to the limit carries nearLimit.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "budget/race-second-commit-loses",
5
5
  "description": "Two evaluations before either commit: only the first commit wins.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "budget/weekly-iso-week",
5
5
  "description": "The weekly counter is keyed on the local ISO week.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "consent/required",
5
5
  "description": "Consent is the first thing about the person that is checked.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "cooldown/three-dismissals",
5
5
  "description": "Three dismissals of a type within 30 days silence it for 7 days from the latest.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.2.0",
4
4
  "name": "dedupe/already-delivered",
5
5
  "description": "A key claimed inside the window rejects the second attempt at the same event.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.2.0",
4
4
  "name": "dedupe/no-key-skips",
5
5
  "description": "With no dedupeKey on the candidate the check skips rather than guessing an identity.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "defer/snooze-as-defer",
5
5
  "description": "snooze with defer true produces a deferred decision with retryAt.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "mode/allow-list",
5
5
  "description": "Only listed operating modes receive proactive messages; an undefined mode passes.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "ordering/kill-switch",
5
5
  "description": "A kill switch that is on stops everything before consent is even read.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "ordering/short-circuit",
5
5
  "description": "Checks run in policy order and stop at the first rejection.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "policy/unknown-check-is-an-error",
5
5
  "description": "The runner never sees this fixture's expectation matter; it documents that compile errors are for the schema, not the runtime. A minimal valid policy passes.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "presets/cn-minor-mode",
5
5
  "description": "cnMinorMode restricts minors to 06:00 to 22:00 China time and one message a day; adults pass.",
@@ -0,0 +1,258 @@
1
+ {
2
+ "spec_version": "1.3.0",
3
+ "since": "1.3.0",
4
+ "name": "presets/in-tcccp",
5
+ "description": "inTcccp needs promotional consent always, and each Schedule-II default-off band passes only when the subscriber opted that band in.",
6
+ "policy": {
7
+ "specVersion": "1.0.0",
8
+ "checks": [
9
+ {
10
+ "preset": "inTcccp"
11
+ }
12
+ ]
13
+ },
14
+ "tests": [
15
+ {
16
+ "description": "no promotional consent",
17
+ "input": {
18
+ "user": {
19
+ "id": "u1",
20
+ "consent": true,
21
+ "proactiveEnabled": true,
22
+ "mode": "normal",
23
+ "intensity": "normal",
24
+ "timezone": "Asia/Kolkata",
25
+ "quietHours": {
26
+ "start": "22:00",
27
+ "end": "08:00"
28
+ },
29
+ "createdAt": "2026-01-01T00:00:00Z",
30
+ "consents": {}
31
+ },
32
+ "candidate": {
33
+ "id": "c1",
34
+ "type": "reminder",
35
+ "priority": "normal",
36
+ "surfaces": [
37
+ "push",
38
+ "feed"
39
+ ]
40
+ },
41
+ "now": "2026-09-04T06:30:00Z"
42
+ },
43
+ "expect": {
44
+ "allowed": false,
45
+ "rejectedBy": "consent:promotional",
46
+ "trace": [
47
+ "consent:promotional"
48
+ ]
49
+ }
50
+ },
51
+ {
52
+ "description": "promotional consent at 12:00 IST passes",
53
+ "input": {
54
+ "user": {
55
+ "id": "u1",
56
+ "consent": true,
57
+ "proactiveEnabled": true,
58
+ "mode": "normal",
59
+ "intensity": "normal",
60
+ "timezone": "Asia/Kolkata",
61
+ "quietHours": {
62
+ "start": "22:00",
63
+ "end": "08:00"
64
+ },
65
+ "createdAt": "2026-01-01T00:00:00Z",
66
+ "consents": {
67
+ "promotional": true
68
+ }
69
+ },
70
+ "candidate": {
71
+ "id": "c1",
72
+ "type": "reminder",
73
+ "priority": "normal",
74
+ "surfaces": [
75
+ "push",
76
+ "feed"
77
+ ]
78
+ },
79
+ "now": "2026-09-04T06:30:00Z"
80
+ },
81
+ "expect": {
82
+ "allowed": true,
83
+ "trace": [
84
+ "consent:promotional",
85
+ "consent:band00to06",
86
+ "consent:band06to08",
87
+ "consent:band08to10",
88
+ "consent:band21to24"
89
+ ]
90
+ }
91
+ },
92
+ {
93
+ "description": "22:30 IST without the band ix opt-in rejects",
94
+ "input": {
95
+ "user": {
96
+ "id": "u1",
97
+ "consent": true,
98
+ "proactiveEnabled": true,
99
+ "mode": "normal",
100
+ "intensity": "normal",
101
+ "timezone": "Asia/Kolkata",
102
+ "quietHours": {
103
+ "start": "22:00",
104
+ "end": "08:00"
105
+ },
106
+ "createdAt": "2026-01-01T00:00:00Z",
107
+ "consents": {
108
+ "promotional": true
109
+ }
110
+ },
111
+ "candidate": {
112
+ "id": "c1",
113
+ "type": "reminder",
114
+ "priority": "normal",
115
+ "surfaces": [
116
+ "push",
117
+ "feed"
118
+ ]
119
+ },
120
+ "now": "2026-09-04T17:00:00Z"
121
+ },
122
+ "expect": {
123
+ "allowed": false,
124
+ "rejectedBy": "consent:band21to24",
125
+ "trace": [
126
+ "consent:promotional",
127
+ "consent:band00to06",
128
+ "consent:band06to08",
129
+ "consent:band08to10",
130
+ "consent:band21to24"
131
+ ]
132
+ }
133
+ },
134
+ {
135
+ "description": "22:30 IST with the band ix opt-in passes",
136
+ "input": {
137
+ "user": {
138
+ "id": "u1",
139
+ "consent": true,
140
+ "proactiveEnabled": true,
141
+ "mode": "normal",
142
+ "intensity": "normal",
143
+ "timezone": "Asia/Kolkata",
144
+ "quietHours": {
145
+ "start": "22:00",
146
+ "end": "08:00"
147
+ },
148
+ "createdAt": "2026-01-01T00:00:00Z",
149
+ "consents": {
150
+ "promotional": true,
151
+ "band21to24": true
152
+ }
153
+ },
154
+ "candidate": {
155
+ "id": "c1",
156
+ "type": "reminder",
157
+ "priority": "normal",
158
+ "surfaces": [
159
+ "push",
160
+ "feed"
161
+ ]
162
+ },
163
+ "now": "2026-09-04T17:00:00Z"
164
+ },
165
+ "expect": {
166
+ "allowed": true,
167
+ "trace": [
168
+ "consent:promotional",
169
+ "consent:band00to06",
170
+ "consent:band06to08",
171
+ "consent:band08to10",
172
+ "consent:band21to24"
173
+ ]
174
+ }
175
+ },
176
+ {
177
+ "description": "04:00 IST rejects when only band 06:00 to 08:00 was opted in",
178
+ "input": {
179
+ "user": {
180
+ "id": "u1",
181
+ "consent": true,
182
+ "proactiveEnabled": true,
183
+ "mode": "normal",
184
+ "intensity": "normal",
185
+ "timezone": "Asia/Kolkata",
186
+ "quietHours": {
187
+ "start": "22:00",
188
+ "end": "08:00"
189
+ },
190
+ "createdAt": "2026-01-01T00:00:00Z",
191
+ "consents": {
192
+ "promotional": true,
193
+ "band06to08": true
194
+ }
195
+ },
196
+ "candidate": {
197
+ "id": "c1",
198
+ "type": "reminder",
199
+ "priority": "normal",
200
+ "surfaces": [
201
+ "push",
202
+ "feed"
203
+ ]
204
+ },
205
+ "now": "2026-09-04T22:30:00Z"
206
+ },
207
+ "expect": {
208
+ "allowed": false,
209
+ "rejectedBy": "consent:band00to06",
210
+ "trace": [
211
+ "consent:promotional",
212
+ "consent:band00to06"
213
+ ]
214
+ }
215
+ },
216
+ {
217
+ "description": "09:00 IST without the band iii opt-in rejects",
218
+ "input": {
219
+ "user": {
220
+ "id": "u1",
221
+ "consent": true,
222
+ "proactiveEnabled": true,
223
+ "mode": "normal",
224
+ "intensity": "normal",
225
+ "timezone": "Asia/Kolkata",
226
+ "quietHours": {
227
+ "start": "22:00",
228
+ "end": "08:00"
229
+ },
230
+ "createdAt": "2026-01-01T00:00:00Z",
231
+ "consents": {
232
+ "promotional": true
233
+ }
234
+ },
235
+ "candidate": {
236
+ "id": "c1",
237
+ "type": "reminder",
238
+ "priority": "normal",
239
+ "surfaces": [
240
+ "push",
241
+ "feed"
242
+ ]
243
+ },
244
+ "now": "2026-09-04T03:30:00Z"
245
+ },
246
+ "expect": {
247
+ "allowed": false,
248
+ "rejectedBy": "consent:band08to10",
249
+ "trace": [
250
+ "consent:promotional",
251
+ "consent:band00to06",
252
+ "consent:band06to08",
253
+ "consent:band08to10"
254
+ ]
255
+ }
256
+ }
257
+ ]
258
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "presets/kakao-brand-message",
5
5
  "description": "kakaoBrandMessage runs 08:00 to 20:50 Korea time regardless of the recipient's zone.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "presets/kr-network-act-50",
5
5
  "description": "krNetworkAct50 needs ad consent always and night consent 21:00 to 08:00.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "presets/telegram-bot",
5
5
  "description": "telegramBot limits one message a second and twenty a minute per channel.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "presets/us-tcpa",
5
5
  "description": "usTcpa allows 08:00 to 21:00 in the user's zone.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "quiet-hours/apia",
5
5
  "description": "Pacific/Apia is UTC+13; 10:00Z is 23:00 local.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.1.0",
4
4
  "name": "quiet-hours/caller-supplied-dates",
5
5
  "description": "A calendar date the caller supplies beats the weekday and the default, in both directions. There is no bundled holiday calendar.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.1.0",
4
4
  "name": "quiet-hours/crosses-midnight-by-day",
5
5
  "description": "A window belongs to the day it opens on, so Friday 18:00 to 02:00 silences Saturday morning. Saturday has no window of its own, so the carry is the only thing that can explain the silence.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "quiet-hours/dst-new-york",
5
5
  "description": "On 2026-03-08 New York springs forward at 02:00; 06:30Z is 01:30 EST, 07:30Z is 03:30 EDT.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "quiet-hours/istanbul",
5
5
  "description": "Quiet hours cross midnight in the user's zone; the priority floor bypasses them.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "quiet-hours/wall-clock",
5
5
  "description": "now is in 2031; the outcome only holds when the implementation used the supplied instant, not the wall clock.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.1.0",
4
4
  "name": "quiet-hours/weekday-schedule",
5
5
  "description": "A Friday and Saturday weekend in Asia/Dubai (UTC+4, no daylight saving). A date beats a weekday beats the default, and null means the day has no quiet hours.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "shadow/reject-continues",
5
5
  "description": "A shadow check records its rejection and evaluation continues.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "trust-ramp/first-week",
5
5
  "description": "During the first days only high priority and above gets through.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "utility/bounded-deferral-cap",
5
5
  "description": "The bound caps t*.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "utility/bounded-deferral",
5
5
  "description": "boundedDeferral moves deliverAt by t* = min(bound, lambda * c / (2 * staleness)) when the candidate is busy.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "utility/floor",
5
5
  "description": "utilityFloor rejects when pAccept is below tau = cFA / (cFA + pNeed * cFN).",