proactive-gate 0.1.2 → 0.2.1

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 CHANGED
@@ -37,11 +37,24 @@ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
37
37
  }
38
38
  ```
39
39
 
40
+ Or start from a policy file and the wiring for your framework, in one command:
41
+
42
+ ```
43
+ npx proactive-gate init --preset usTcpa --framework mastra
44
+ ```
45
+
46
+ That writes `proactive-gate.policy.json` with the ten checks in order, appends the
47
+ preset you named, and prints the preset's own source next to the few lines that plug
48
+ the gate into that framework. `npx proactive-gate init --list` shows the fourteen
49
+ platform and legal presets and the four frameworks.
50
+
40
51
  Zero dependencies. TypeScript. Node 20 or newer. Framework-agnostic: the gate sits
41
52
  between "the model produced something" and "the user's phone buzzed", whichever
42
53
  model or framework produced it. Examples: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
43
54
  [`examples/mastra.ts`](examples/mastra.ts), [`examples/langgraph.ts`](examples/langgraph.ts), and a
44
- replayable policy in [`examples/policy.js`](examples/policy.js). API reference: [`docs/api`](docs/api/README.md).
55
+ replayable policy in [`examples/policy.json`](examples/policy.json). Docs and a browser playground:
56
+ [bubblegunn.github.io/proactive-gate](https://bubblegunn.github.io/proactive-gate/). API reference:
57
+ [`docs/api`](docs/api/README.md). Python: [`python/`](python/README.md).
45
58
 
46
59
  ## What a decision looks like
47
60
 
@@ -67,7 +80,10 @@ replayable policy in [`examples/policy.js`](examples/policy.js). API reference:
67
80
  }
68
81
  ```
69
82
 
70
- <p align="center"><img src="assets/trace.png" width="900" alt="A real decision trace: eight checks ran, quiet hours rejected, each with its reason and cost"></p>
83
+ <p align="center"><img src="docs/assets/trace.svg" width="900" alt="Two real decision traces side by side: candidate a1 rejected by quietHours after eight checks, candidate a5 allowed after thirteen, each check with its outcome, reason and cost"></p>
84
+
85
+ The figure is drawn from the replay's `--json` output by `node scripts/trace-svg.mjs`, every
86
+ line verbatim; the left decision is the one printed above.
71
87
 
72
88
  With one gate and a logged reason, "why was the user not told about this" has an
73
89
  answer. With checks scattered through a pipeline, the honest answer is "somewhere,
@@ -96,6 +112,22 @@ consumed in check order at commit, so when a weekly check passes and the daily o
96
112
  refuses, that weekly unit is spent without a delivery. It only happens when two commits
97
113
  race after a shared evaluate.
98
114
 
115
+ ### Two limits you should know before you adopt this
116
+
117
+ Neither is a bug, and both are pinned by tests so a future change has to be deliberate.
118
+
119
+ **The week is the ISO week, so the weekly budget refills on Monday.** Where the working
120
+ week runs Sunday to Thursday, that refill lands one day in: a user who spends the budget on
121
+ Sunday has it back on Monday, with four working days still to run. Changing the key would
122
+ move every counter already in your store, so it is documented rather than quietly altered.
123
+ Pass your own budget check keyed how you like if the ISO week is wrong for your users.
124
+
125
+ **Quiet hours are a single window, the same on every day of the week.** A user carries one
126
+ `start` and one `end`, so a Friday window, a Shabbat window or a public holiday cannot be
127
+ expressed. The day of the week is never read. If you need one, write a check: it is an
128
+ object with an `id` and a `run`, it composes in the order you choose, and the trace will
129
+ show it firing beside the built-in ones.
130
+
99
131
  Order is a design decision and it should be visible. Consent has to come before
100
132
  everything. Quiet hours have to come before the budget, or a rejected candidate
101
133
  consumes a delivery it never made. Reorder freely; the trace will show what you did.
@@ -138,6 +170,168 @@ Mark a check `nonRejecting: true` when it may only move timing or narrow surface
138
170
  gate then ignores a reject from it and says so in the trace, so a bug in a timing model
139
171
  cannot silence a user.
140
172
 
173
+ ## A policy is data
174
+
175
+ The same checks as a JSON document, so a product team can change the rules without a
176
+ deploy and the same file runs in TypeScript, in Python, in the CLI and in the
177
+ [playground](https://bubblegunn.github.io/proactive-gate/playground/):
178
+
179
+ ```json
180
+ {
181
+ "specVersion": "1.0.0",
182
+ "checks": [
183
+ { "id": "consent" },
184
+ { "id": "snooze", "defer": true },
185
+ { "id": "quietHours", "priorityFloor": "high" },
186
+ { "preset": "usTcpa" },
187
+ { "id": "utilityFloor", "costFalseAlarm": 1, "costMissedHelp": 2, "shadow": true },
188
+ { "id": "dailyBudget", "limit": 3, "bypassPriority": "critical", "nearLimit": 0.67 }
189
+ ]
190
+ }
191
+ ```
192
+
193
+ ```ts
194
+ const gate = createGate({ policy: JSON.parse(await readFile("policy.json", "utf8")), store });
195
+ ```
196
+
197
+ Each entry names a check `id` or a `preset` plus that check's options. An unknown id throws
198
+ and names the known ones. `compilePolicy` is exported for callers that want the check list,
199
+ and the schema is at [`spec/schema/policy.schema.json`](spec/schema/policy.schema.json).
200
+ `examples/policy.js` stays as the escape hatch for checks that need functions.
201
+
202
+ ## Defer, shadow mode, near-limit notes and hooks
203
+
204
+ A check can `defer` instead of rejecting: the decision has `allowed: false`, `deferredBy` and
205
+ `retryAt`, and the caller knows when to try again. `snooze({ defer: true })` is the built-in
206
+ example.
207
+
208
+ A check with `shadow: true` runs and is traced with its real outcome, but cannot stop the
209
+ message; its id lands in `decision.shadowed`. Ship a new rule in shadow for a week, count how
210
+ often it would have fired, then turn it on.
211
+
212
+ Budgets report `nearLimit: { used, limit }` on the pass that reaches the threshold (80 percent
213
+ by default), listed under `decision.nearLimit`, so a dashboard can show who is about to go
214
+ quiet.
215
+
216
+ `hooks: { before, after, error, finally }` observe every check with its cost in milliseconds;
217
+ a hook that throws is routed to `error` and never changes the decision. `examples/otel.ts`
218
+ turns them into one span per check. Every decision has an `id`, and `commit` is idempotent on
219
+ it: a retry after a timeout does not consume a second unit.
220
+
221
+ ## Optional checks, fed by your own model
222
+
223
+ Both ship off. They read numbers the caller puts on the candidate.
224
+
225
+ - `utilityFloor({ costFalseAlarm, costMissedHelp })` acts only when `candidate.pAccept` clears
226
+ `tau = cFA / (cFA + pNeed * cFN)` (`pNeed` defaults to 1) and skips when there is no
227
+ `pAccept`. That threshold is the classical Bayes decision boundary: alerting costs
228
+ `(1 - p) * cFA`, silence costs `p * cFN`, so you alert when the first is the smaller.
229
+ The alerting application is [Horvitz, Jacobs and Hovel, "Attention-Sensitive Alerting",
230
+ UAI 1999](https://arxiv.org/abs/1301.6707), whose system is named Priorities.
231
+ - `boundedDeferral({ lambda, interruptCost, staleness, boundSeconds })` never rejects. When
232
+ `candidate.busy` is true it moves `deliverAt` to `now + t*`, with
233
+ `t* = min(bound, lambda * interruptCost / (2 * staleness))`; the defaults give 116 seconds.
234
+
235
+ Neither check ships a model, a cost or a probability. `costFalseAlarm`, `costMissedHelp`,
236
+ `interruptCost` and `staleness` are yours to measure, and the package has no opinion about
237
+ what an interruption costs your users. The field measurement people usually reach for is
238
+ [Iqbal and Horvitz, "Disruption and recovery of computing tasks", CHI
239
+ 2007](https://erichorvitz.com/CHI_2007_Iqbal_Horvitz.pdf), which logged real users and put
240
+ the return to a suspended task in the region of 11 to 16 minutes. The widely repeated "23
241
+ minutes 15 seconds" figure is not from a peer-reviewed paper and is not used here.
242
+
243
+ `boundedDeferral` implements the derivation in [Achlioptas and Horvitz, "Principles of
244
+ Bounded Deferral for Balancing Information Awareness with
245
+ Interruption"](http://erichorvitz.com/Bounded_Deferral.pdf): expected cost is stationary
246
+ where `f'(t0) = lambda * c`, so a quadratic staleness `f(t) = s * t²` gives
247
+ `t* = lambda * c / (2 * s)`.
248
+
249
+ ## Which defaults are measured and which are ours
250
+
251
+ Every default here is either taken from a study, which is then named, or chosen by
252
+ judgement, which is then admitted. There is one of the first kind.
253
+
254
+ | default | where it comes from |
255
+ |---|---|
256
+ | `lambda = 1/43` in `boundedDeferral` | Measured. Achlioptas and Horvitz above: 113 Microsoft employees (42 program managers, 25 developers, 19 testers, 10 administrators, 9 managers, 4 in sales and marketing, 4 research scientists), three sequential business days between 10am and 4pm, 4,803 busy situations, mean busy session 43.12 s, standard deviation 51.79 s |
257
+ | `staleness = 0.0001`, `boundSeconds = 240` | Scale choices. Only the ratio `interruptCost / staleness` changes `t*`, so this pair is one way to write "a few minutes". Nothing fixes either number |
258
+ | `trustRamp` 7 days | Ours. No study sets it |
259
+ | `dismissalCooldown` 3 in 30 days buying 7 days | Ours. A dismissal is the clearest signal a user gives, so the shape is defensible; the three numbers are not from anywhere |
260
+ | `dailyBudget` 5 | Ours, in a supported direction. Pielot and Rello (below) cite an in-situ log study where participants received a median of 63.5 notifications a day, so a handful sits far below the ambient load. Nothing in that work says five |
261
+
262
+ The spread inside the one measured number is worth more than the number. The same paper's
263
+ two-subject analysis puts the mean time to a lower-cost state after an alert at 11 seconds
264
+ for one person and 101 seconds for the other, so the variation between two people is larger
265
+ than the default itself. Measure your own users before you trust it.
266
+
267
+ ### Deferring is supported; silence is not free
268
+
269
+ The strongest evidence that deferral works at all is [Okoshi, Tsubouchi and Tokuda,
270
+ "Real-world large-scale study on adaptive notification scheduling on smartphones",
271
+ *Pervasive and Mobile Computing* 50:1-24
272
+ (2018)](https://keio.elsevierpure.com/en/publications/real-world-large-scale-study-on-adaptive-notification-scheduling-/):
273
+ the Yahoo! JAPAN Android app, more than 680,000 users over three weeks, where holding a
274
+ notification until an interruptible moment was detected cut response time by 49.7 percent
275
+ against immediate delivery. That supports the direction. It says nothing about any window,
276
+ budget or cooldown in this package.
277
+
278
+ The counterweight belongs here too, because a gate that suppresses is not free. In [Pielot
279
+ and Rello, "Productive, Anxious, Lonely: 24 Hours Without Push Notifications", MobileHCI
280
+ 2017](https://arxiv.org/abs/1612.02314), 30 volunteers switched notifications off for a day.
281
+ They were less distracted, and they also worried about missing information, checked their
282
+ phones more often, and felt less connected to the people around them. Fifteen of the thirty
283
+ agreed they were afraid of missing something urgent. Three people approached for the study
284
+ refused outright, because their workplace expected them to be reachable. A silence your user
285
+ did not choose costs them something, and that cost does not appear in any trace this library
286
+ prints.
287
+
288
+ ## Presets: platform quotas and legal limits, with sources
289
+
290
+ ```ts
291
+ import { presets } from "proactive-gate/presets";
292
+ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessage()] });
293
+ ```
294
+
295
+ | preset | encodes |
296
+ |---|---|
297
+ | `lineMessagingApi({ plan })` | monthly push budget by LINE plan: 200, 5,000 or 30,000 |
298
+ | `wechatSubscriptionMessage` | one message per subscription opt-in |
299
+ | `wechatCustomerService` | within 48 h of the user's last message, at most 5 |
300
+ | `wechatTemplateMessage` | only after a user action, 3 templates a day |
301
+ | `wecomAppMessage` | 30 a minute and 1,000 an hour per member |
302
+ | `kakaoAlimtalk` | consent only; AlimTalk has no time-of-day rule |
303
+ | `kakaoBrandMessage` | advertising consent, 08:00 to 20:50 Asia/Seoul |
304
+ | `krNetworkAct50` | advertising consent, plus night consent for 21:00 to 08:00 local |
305
+ | `jpAntiSpamLaw` | opt-in |
306
+ | `cnMinorMode` | for minors: 06:00 to 22:00 Asia/Shanghai and one a day |
307
+ | `usTcpa` | 08:00 to 21:00 at the user's local time (47 CFR 64.1200) |
308
+ | `euEprivacy` | marketing consent with the soft opt-in for existing customers |
309
+ | `telegramBot` | 1 a second and 20 a minute per chat |
310
+ | `slackApp` | 1 a second per channel |
311
+
312
+ Each preset carries `sources` (the pages the numbers come from) and a `note` on what it leaves
313
+ out. Reviewable defaults, not legal advice: several official sources disagree with each other,
314
+ and the note says which value was chosen and why.
315
+
316
+ **Read the scope before you reach for a legal preset.** Every instrument above regulates
317
+ *commercial* communication. `usTcpa`, `euEprivacy`, `krNetworkAct50` and `jpAntiSpamLaw` are
318
+ marketing rules, so they bind your message only when the message itself is commercial. A
319
+ reminder your user asked for is not advertising, and pulling in a marketing preset for it
320
+ imports a restriction the law never placed on you, which is its own kind of wrong answer.
321
+ Use them when the candidate is promotional; when it is not, the platform quotas and your own
322
+ quiet hours are the honest constraints.
323
+
324
+ That scope test is also why some jurisdictions people ask for are missing. Canada's CASL and
325
+ Australia's Spam Act 2003 set consent, identification and unsubscribe duties, and neither
326
+ carries a time-of-day rule at all. The Brazilian window quoted around the web comes from bill
327
+ PLS 48/2018, a proposal rather than enacted law, and it covers telemarketing calls. India is
328
+ the interesting one: the widely repeated "9am to 9pm" is not what the primary text says. The
329
+ Telecom Commercial Communications Customer Preference Regulations make time bands a
330
+ *preference the subscriber registers* with their access provider, alongside content category
331
+ and day type, not a fixed statutory quiet window, and the secondary sources that quote a
332
+ window disagree with each other about whether it starts at 09:00 or 10:00. A preset built on
333
+ that would encode a number no primary source states, so there is none.
334
+
141
335
  ## The budget is enforced at commit, not at evaluate
142
336
 
143
337
  Two instances can both evaluate a candidate for the same user, both see four of
@@ -210,22 +404,115 @@ to keep once the checks are scattered:
210
404
  - A policy can be replayed over a day of real candidates before it ships, and a non-rejecting
211
405
  check cannot reject even if a bug makes it try.
212
406
 
407
+ Those are claims, so the repository runs them. `npm run bench:compare` replays a
408
+ committed day of 21 candidates for 7 users through `bench/naive.mjs`, an honest
409
+ hand-rolled policy of five `if` statements, and through a gate built from
410
+ `bench/fixtures/policy.json`:
411
+
412
+ ```
413
+ gate: 11 sent, 10 stopped
414
+ hand-rolled: 13 sent, 8 stopped
415
+
416
+ 6 disagreements, and none of them is a matter of taste:
417
+ a5 a critical alert: the gate lets priority bypass the cap, the cap in the if statements does not
418
+ b1 a two-day-old account: the gate holds normal messages back for a week, the if statements never knew
419
+ c1 the user pressed snooze: the gate defers to when it ends, the if statements have no snooze
420
+ e1 three dismissals of this type: the gate is silent for a week, the if statements do not track outcomes
421
+ f4 01:00 in Tokyo, a new local day: the gate resets the cap, the UTC-day key stays on yesterday for nine more hours
422
+ g4 18:00 in Los Angeles, still the same local day: the UTC-day key already rolled, so the cap pays out twice
423
+ ```
424
+
425
+ The hand-rolled policy is not a straw man. It checks consent, enabled, mute, quiet
426
+ hours and a daily cap, which is what actually gets written, and it takes the three
427
+ shortcuts that actually get taken: a fixed UTC offset per zone, the cap keyed by the
428
+ UTC calendar day, and the cap read then written. `test/naive.test.mjs` pins each one
429
+ against a real instant:
430
+
431
+ - The clocks change. At `2026-11-01T12:30:00Z` New York has left daylight time, so it is
432
+ 07:30 there and inside quiet hours; an offset captured in the summer computes 08:00 and
433
+ sends. That is twice a year, for every zone that observes it.
434
+ - The day boundary is local. The same UTC-day key silences the Tokyo user for the nine
435
+ hours between local midnight and 09:00, and hands the Los Angeles user a second full
436
+ budget at 17:00 while it is still their afternoon.
437
+ - Two deliveries are in flight. Read, compare, write lets both take the last slot, and the
438
+ counter still reads 2 afterwards, so nothing looks wrong. `commit()` takes the unit with
439
+ an atomic increment and returns `false` to the loser.
440
+
213
441
  A feature-flag system does a different job better: rolling a behaviour out to a percentage
214
442
  of users, per-tenant overrides, and an audit trail of who flipped what. Use flags to decide
215
443
  whether the gate runs at all, and the gate to decide whether this message reaches this
216
444
  person now.
217
445
 
218
- ## Integrations
446
+ ## Adapters
219
447
 
220
- | framework | example | where the gate sits |
448
+ | subpath | framework | where the gate sits |
221
449
  |---|---|---|
222
- | Vercel AI SDK | [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts) | after `generateText`, before the push |
223
- | Mastra | [`examples/mastra.ts`](examples/mastra.ts) | after `agent.generate`, before the send |
224
- | LangGraph | [`examples/langgraph.ts`](examples/langgraph.ts) | inside the `notify` node, before the tool call |
450
+ | `proactive-gate/ai-sdk` | Vercel AI SDK | answers a tool's `needsApproval` ([`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts); runnable offline: [`examples/ai-sdk/`](examples/ai-sdk/)) |
451
+ | `proactive-gate/mastra` | Mastra | an output processor before the send ([`examples/mastra.ts`](examples/mastra.ts); runnable offline: [`examples/mastra/`](examples/mastra/)) |
452
+ | `proactive-gate/langchain` | LangChain | middleware around the send tool |
453
+ | `proactive-gate/openai-agents` | OpenAI Agents | a guardrail |
454
+ | `npx proactive-gate hook` | Claude Code | a `PreToolUse` hook ([`examples/claude-code-hook.json`](examples/claude-code-hook.json)) |
455
+
456
+ The adapters are typed against the shape of a call, not against the framework package, so
457
+ nothing else has to be installed. Each denies with the gate's reason and commits the budget on
458
+ approval. The pattern is the same everywhere: the model decides whether there is something to
459
+ say, `gate.evaluate` decides whether it may be said now, and `gate.commit` runs right before
460
+ the message leaves. [`examples/langgraph.ts`](examples/langgraph.ts) shows the same thing
461
+ inside a LangGraph node.
462
+
463
+ Two of the examples run without the framework installed and without a network: `node
464
+ examples/mastra/run.mjs` makes the same `processOutputResult` call Mastra makes, over a day of
465
+ candidates with the clock taken from each line, and `node examples/ai-sdk/run.mjs` answers a
466
+ day of tool-approval requests, one of which is a critical alert that a legal window (the TCPA
467
+ preset) still refuses. Both are part of `npm run examples` and of the test suite.
468
+
469
+ ## Python
470
+
471
+ ```
472
+ pip install proactive-gate
473
+ ```
474
+
475
+ ```python
476
+ from proactive_gate import Gate
477
+ gate = Gate.from_policy(policy) # the same policy.json
478
+ decision = gate.evaluate(inp)
479
+ if decision.allowed and gate.commit(decision, inp): send(...)
480
+ ```
225
481
 
226
- The pattern is the same everywhere: the model decides whether there is something to say,
227
- `gate.evaluate` decides whether it may be said now, and `gate.commit` runs right before the
228
- message leaves.
482
+ `python/` is a sibling, not a port that drifts: it passes every fixture under `spec/fixtures`
483
+ through a sync `Gate` and an `AsyncGate` (Redis over `redis.asyncio`), with mypy strict, on
484
+ Python 3.11 and 3.13 in CI. See [`python/README.md`](python/README.md).
485
+
486
+ ## Properties, not just examples
487
+
488
+ `test/properties.test.ts` generates gates, users and candidates from a seeded
489
+ 32-bit PRNG and asserts what has to hold for all of them, rather than for the
490
+ cases someone thought of:
491
+
492
+ - The trace is always a prefix of the declared check order. Nothing is skipped,
493
+ nothing is reordered, every check reports exactly once, and a stopped decision
494
+ ends on the check that stopped it with a reason attached.
495
+ - A check marked `nonRejecting` cannot stop a decision even when it returns a
496
+ rejection on purpose.
497
+ - However many deliveries race, `commit()` hands out exactly `min(racers, limit)`
498
+ units, and replaying one decision any number of times spends one.
499
+ - `MemoryStore` and `SqliteStore` answer the same random sequence of `get`, `set`,
500
+ `incr`, `del` and clock movement identically, TTLs included.
501
+
502
+ The generator is forty lines because the package has no dependencies; a property
503
+ library would shrink failures better. Each assertion prints its seed, so a failure
504
+ reproduces exactly. The race property was checked against a mutant: rewriting
505
+ `consume` as read-then-write, the shortcut in `bench/naive.mjs`, makes it fail.
506
+
507
+ ## The spec, and writing a second implementation
508
+
509
+ [`spec/SPEC.md`](spec/SPEC.md) states the behaviour as numbered requirements, and
510
+ [`spec/fixtures`](spec/fixtures) holds language-neutral cases: the DST edge in
511
+ America/New_York, Pacific/Apia, a wall-clock case in 2031, atomic commit, the ISO week,
512
+ deferral, shadow mode, the optional checks and four presets. The TypeScript tests and the
513
+ Python tests both run all of them; `npx proactive-gate replay --fixtures spec/fixtures` runs
514
+ them from the command line. A third implementation starts from the fixtures, not from this
515
+ source.
229
516
 
230
517
  ## Performance
231
518
 
@@ -275,11 +562,18 @@ Tian Pan's
275
562
  essay argues the same case from the product side and suggests a daily cap of three
276
563
  to five; `defaultChecks({ dailyLimit })` defaults to five.
277
564
 
565
+ The shape has older relatives. Matrix push rules are an ordered list where the first matching
566
+ rule decides. Android notification channels and iOS interruption levels give the user a
567
+ per-type switch and a priority floor that bypasses quiet time. Horvitz's work on mixed
568
+ initiative supplied the two optional checks. This package puts those ideas in one list with a
569
+ trace, and adds the part they leave out: the budget consumed at send time.
570
+
278
571
  ## Development
279
572
 
280
573
  ```
281
574
  npm ci
282
- npm test # tsc build, then node:test over dist/test
575
+ npm test # tsc build, spec-lint, then node:test over dist/test
576
+ cd python && pytest # the Python sibling against the same fixtures
283
577
  ```
284
578
 
285
579
  MIT.
package/README.tr.md CHANGED
@@ -33,7 +33,9 @@ Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağıms
33
33
  şey üretti" ile "kullanıcının telefonu titredi" arasında durur; hangi model ya da framework
34
34
  üretmiş olursa olsun. Örnekler: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
35
35
  [`examples/mastra.ts`](examples/mastra.ts) ve yeniden oynatılabilir bir politika olarak
36
- [`examples/policy.js`](examples/policy.js).
36
+ [`examples/policy.json`](examples/policy.json). Dokümantasyon ve tarayıcıda oyun alanı:
37
+ [bubblegunn.github.io/proactive-gate](https://bubblegunn.github.io/proactive-gate/). Python:
38
+ [`python/`](python/README.md).
37
39
 
38
40
  ## Bir karar neye benzer
39
41
 
@@ -59,7 +61,10 @@ Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağıms
59
61
  }
60
62
  ```
61
63
 
62
- <p align="center"><img src="assets/trace.png" width="900" alt="Gerçek bir karar izi: sekiz kontrol çalıştı, sessiz saatler reddetti, her biri gerekçesi ve maliyetiyle"></p>
64
+ <p align="center"><img src="docs/assets/trace.svg" width="900" alt="Yan yana iki gerçek karar izi: a1 adayı sekiz kontrolden sonra sessiz saatlerde reddedildi, a5 adayı on üç kontrolden sonra geçti; her kontrol sonucu, gerekçesi ve maliyetiyle"></p>
65
+
66
+ Şekil, replay komutunun `--json` çıktısından `node scripts/trace-svg.mjs` ile çizilir; her satır
67
+ olduğu gibi alınmıştır.
63
68
 
64
69
  Tek kapı ve kayıtlı bir gerekçe ile "kullanıcıya bu neden söylenmedi" sorusunun bir cevabı
65
70
  olur. Kontroller bir boru hattına dağılmışken dürüst cevap "bir yerde bir şey false döndü"
@@ -123,6 +128,200 @@ Bir kontrol yalnızca zamanlamayı taşıyabiliyor ya da yüzeyleri daraltabiliy
123
128
  `nonRejecting: true` işaretleyin; kapı ondan gelen bir reddi yok sayar ve bunu izde söyler,
124
129
  böylece bir zamanlama modelindeki hata bir kullanıcıyı susturamaz.
125
130
 
131
+ ## Politika bir veridir
132
+
133
+ Aynı kontroller bir JSON belgesi olarak da yazılabilir; ürün ekibi kuralları dağıtım yapmadan
134
+ değiştirir ve aynı dosya TypeScript'te, Python'da, CLI'da ve
135
+ [oyun alanında](https://bubblegunn.github.io/proactive-gate/playground/) çalışır:
136
+
137
+ ```json
138
+ {
139
+ "specVersion": "1.0.0",
140
+ "checks": [
141
+ { "id": "consent" },
142
+ { "id": "snooze", "defer": true },
143
+ { "id": "quietHours", "priorityFloor": "high" },
144
+ { "preset": "usTcpa" },
145
+ { "id": "utilityFloor", "costFalseAlarm": 1, "costMissedHelp": 2, "shadow": true },
146
+ { "id": "dailyBudget", "limit": 3, "bypassPriority": "critical", "nearLimit": 0.67 }
147
+ ]
148
+ }
149
+ ```
150
+
151
+ ```ts
152
+ const gate = createGate({ policy: JSON.parse(await readFile("policy.json", "utf8")), store });
153
+ ```
154
+
155
+ Her girdi bir kontrol `id`'si ya da bir `preset` ve o kontrolün seçeneklerini taşır. Bilinmeyen
156
+ bir id hata fırlatır ve bilinenleri sayar. Şema
157
+ [`spec/schema/policy.schema.json`](spec/schema/policy.schema.json) dosyasındadır;
158
+ `examples/policy.js`, fonksiyon gerektiren kontroller için kaçış yolu olarak durur.
159
+
160
+ ## Erteleme, gölge modu, sınıra yakınlık notları ve kancalar
161
+
162
+ Bir kontrol reddetmek yerine `defer` diyebilir: karar `allowed: false`, `deferredBy` ve
163
+ `retryAt` taşır, çağıran ne zaman tekrar deneyeceğini bilir. `snooze({ defer: true })` yerleşik
164
+ örnektir.
165
+
166
+ `shadow: true` işaretli bir kontrol çalışır ve izde gerçek sonucuyla görünür, ama mesajı
167
+ durduramaz; id'si `decision.shadowed` listesine düşer. Yeni bir kuralı bir hafta gölgede
168
+ çalıştırın, kaç kez ateşleyeceğini sayın, sonra açın.
169
+
170
+ Bütçeler eşiğe (varsayılan yüzde 80) ulaşan geçişte `nearLimit: { used, limit }` bildirir;
171
+ `decision.nearLimit` altında listelenir, böylece bir pano kimin susmak üzere olduğunu gösterir.
172
+
173
+ `hooks: { before, after, error, finally }` her kontrolü milisaniye maliyetiyle gözler; hata
174
+ fırlatan bir kanca `error` kancasına yönlendirilir ve kararı asla değiştirmez.
175
+ `examples/otel.ts` bunları kontrol başına bir span'e çevirir. Her kararın bir `id`'si vardır ve
176
+ `commit` bu id üzerinde tekrarlanabilir: zaman aşımından sonraki bir yeniden deneme ikinci
177
+ bir birim tüketmez.
178
+
179
+ ## İsteğe bağlı, kendi modelinizin beslediği kontroller
180
+
181
+ İkisi de kapalı gelir; adayın üzerine çağıranın koyduğu sayıları okurlar.
182
+
183
+ - `utilityFloor({ costFalseAlarm, costMissedHelp })` yalnızca `candidate.pAccept` değeri
184
+ `tau = cFA / (cFA + pNeed * cFN)` eşiğini geçtiğinde konuşur (`pNeed` varsayılanı 1);
185
+ `pAccept` yoksa atlar. Bu eşik klasik Bayes karar sınırıdır: konuşmanın maliyeti
186
+ `(1 - p) * cFA`, susmanın maliyeti `p * cFN`, hangisi küçükse o seçilir. Uyarı alanındaki
187
+ karşılığı [Horvitz, Jacobs ve Hovel, "Attention-Sensitive Alerting", UAI
188
+ 1999](https://arxiv.org/abs/1301.6707); o makaledeki sistemin adı Priorities.
189
+ - `boundedDeferral({ lambda, interruptCost, staleness, boundSeconds })` asla reddetmez.
190
+ `candidate.busy` doğruysa `deliverAt` değerini `now + t*` yapar;
191
+ `t* = min(bound, lambda * interruptCost / (2 * staleness))`, varsayılanlar 116 saniye verir.
192
+ Türetim [Achlioptas ve Horvitz, "Principles of Bounded
193
+ Deferral"](http://erichorvitz.com/Bounded_Deferral.pdf) makalesinden.
194
+
195
+ ## Hangi varsayılan ölçüldü, hangisi bizim tercihimiz
196
+
197
+ Buradaki her varsayılan ya bir çalışmadan geliyor ve kaynağı yazılıyor, ya da bir kanaat ve
198
+ bunu söylüyoruz. Birinci türden tek bir tane var.
199
+
200
+ | varsayılan | nereden geliyor |
201
+ |---|---|
202
+ | `boundedDeferral` içindeki `lambda = 1/43` | Ölçüm. Yukarıdaki makale: 113 çalışan, üç ardışık iş günü, 10.00 ile 16.00 arası, 4.803 meşgul durum, ortalama meşguliyet süresi 43,12 saniye, standart sapma 51,79 saniye |
203
+ | `staleness = 0.0001`, `boundSeconds = 240` | Ölçek tercihi. `t*` yalnızca `interruptCost / staleness` oranına bağlı; bu çift "birkaç dakika" demenin bir yolu, iki sayıyı da sabitleyen bir bulgu yok |
204
+ | `trustRamp` 7 gün | Bizim. Hiçbir çalışma bu sayıyı vermiyor |
205
+ | `dismissalCooldown` 30 günde 3 kapatma, 7 gün sessizlik | Bizim. Kapatma, kullanıcının verdiği en net sinyal olduğu için biçim savunulabilir; üç sayı bize ait |
206
+ | `dailyBudget` 5 | Bizim, ama yönü destekli. Pielot ve Rello'nun aktardığı yerinde günlük kayıt çalışmasında katılımcılar günde ortanca 63,5 bildirim alıyor; bir avuç mesaj bunun çok altında. O çalışma "beş" demiyor |
207
+
208
+ Ölçülen tek sayının içindeki dağılım, sayının kendisinden değerli: aynı makalenin iki kişilik
209
+ çözümlemesinde uyarı sonrası düşük maliyetli duruma geçiş ortalaması birinde 11, diğerinde
210
+ 101 saniye. İki kişi arasındaki fark varsayılanın kendisinden büyük.
211
+
212
+ ### Ertelemenin dayanağı var, susmanın bedeli de var
213
+
214
+ Ertelemenin işe yaradığına dair en güçlü kanıt [Okoshi, Tsubouchi ve Tokuda, *Pervasive and
215
+ Mobile Computing* 50:1-24
216
+ (2018)](https://keio.elsevierpure.com/en/publications/real-world-large-scale-study-on-adaptive-notification-scheduling-/):
217
+ Yahoo! JAPAN Android uygulaması, 680.000'den fazla kullanıcı, üç hafta; bildirimi uygun ana
218
+ kadar bekletmek yanıt süresini yüzde 49,7 kısaltmış. Bu, yönü destekler; bu paketteki
219
+ hiçbir pencereyi, bütçeyi veya bekleme süresini desteklemez.
220
+
221
+ Karşı ağırlık da burada durmalı, çünkü susturan bir kapı bedelsiz değil. [Pielot ve Rello,
222
+ MobileHCI 2017](https://arxiv.org/abs/1612.02314) çalışmasında 30 gönüllü bir gün boyunca
223
+ bildirimleri kapatmış. Daha az dağılmışlar, ama aynı zamanda bir şeyi kaçırmaktan
224
+ endişelenmiş, telefonlarına daha sık bakmış ve çevrelerinden kopuk hissetmişler. Otuz kişiden
225
+ on beşi acil bir şeyi kaçırmaktan korktuğunu söylemiş. Çalışma için görüşülen üç kişi,
226
+ işyerinde sürekli ulaşılabilir olmaları beklendiği için katılmayı reddetmiş. Kullanıcının
227
+ seçmediği bir sessizliğin bir bedeli var ve o bedel bu kütüphanenin yazdığı hiçbir izde
228
+ görünmüyor.
229
+
230
+ ## Benimsemeden önce bilmeniz gereken iki sınır
231
+
232
+ İkisi de hata değil ve ikisi de testle sabitlendi, böylece ileride değişecekse bilerek değişir.
233
+
234
+ **Hafta, ISO haftasıdır; haftalık bütçe pazartesi yenilenir.** Çalışma haftası pazardan
235
+ perşembeye uzanan yerlerde bu yenilenme haftanın birinci gününe denk gelir: pazar günü
236
+ bütçesini harcayan bir kullanıcı pazartesi sabahı bütçesini geri alır ve önünde hâlâ dört
237
+ iş günü vardır. Anahtarı değiştirmek deponuzdaki bütün sayaçları kaydıracağı için bunu
238
+ sessizce değiştirmek yerine yazıyoruz. ISO haftası sizin kullanıcılarınız için yanlışsa
239
+ kendi bütçe kontrolünüzü istediğiniz anahtarla yazabilirsiniz.
240
+
241
+ **Sessiz saatler tek bir penceredir ve haftanın her günü aynıdır.** Kullanıcıda tek bir
242
+ `start` ve tek bir `end` vardır; cuma penceresi, Şabat penceresi veya resmî tatil
243
+ tanımlanamaz. Haftanın günü hiç okunmaz. Böyle bir kurala ihtiyacınız varsa kendi
244
+ kontrolünüzü yazın: `id` ve `run` taşıyan bir nesnedir, istediğiniz sırada dizilir ve izde
245
+ yerleşik kontrollerin yanında görünür.
246
+
247
+ ## Hazır paketler: platform kotaları ve yasal sınırlar, kaynaklarıyla
248
+
249
+ ```ts
250
+ import { presets } from "proactive-gate/presets";
251
+ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessage()] });
252
+ ```
253
+
254
+ | paket | ne kodlar |
255
+ |---|---|
256
+ | `lineMessagingApi({ plan })` | LINE planına göre aylık push bütçesi: 200, 5.000 ya da 30.000 |
257
+ | `wechatSubscriptionMessage` | abonelik onayı başına bir mesaj |
258
+ | `wechatCustomerService` | kullanıcının son mesajından sonraki 48 saat içinde en çok 5 |
259
+ | `wechatTemplateMessage` | yalnızca kullanıcı eyleminden sonra, günde 3 şablon |
260
+ | `wecomAppMessage` | üye başına dakikada 30 ve saatte 1.000 |
261
+ | `kakaoAlimtalk` | yalnızca rıza; AlimTalk'ta saat kuralı yok |
262
+ | `kakaoBrandMessage` | reklam rızası, 08:00 ile 20:50 Asia/Seoul |
263
+ | `krNetworkAct50` | reklam rızası, ayrıca 21:00 ile 08:00 yerel saat için gece rızası |
264
+ | `jpAntiSpamLaw` | opt-in |
265
+ | `cnMinorMode` | reşit olmayanlar için: 06:00 ile 22:00 Asia/Shanghai ve günde bir |
266
+ | `usTcpa` | kullanıcının yerel saatiyle 08:00 ile 21:00 (47 CFR 64.1200) |
267
+ | `euEprivacy` | pazarlama rızası, mevcut müşteriler için yumuşak opt-in |
268
+ | `telegramBot` | sohbet başına saniyede 1 ve dakikada 20 |
269
+ | `slackApp` | kanal başına saniyede 1 |
270
+
271
+ Her paket `sources` (sayıların geldiği sayfalar) ve neyi dışarıda bıraktığını söyleyen bir
272
+ `note` taşır. Gözden geçirilebilir varsayılanlar, hukuki tavsiye değil: birkaç resmi kaynak
273
+ birbiriyle çelişir ve not hangi değerin neden seçildiğini söyler.
274
+
275
+ **Yasal bir pakete uzanmadan önce kapsamını okuyun.** Yukarıdaki bütün düzenlemeler *ticari*
276
+ iletişimi düzenler. `usTcpa`, `euEprivacy`, `krNetworkAct50` ve `jpAntiSpamLaw` birer pazarlama
277
+ kuralıdır; yani mesajınızı ancak mesajın kendisi ticari olduğunda bağlar. Kullanıcının kendi
278
+ istediği bir hatırlatma reklam değildir ve onun için pazarlama paketi kullanmak, yasanın size
279
+ hiç koymadığı bir kısıtı kendi elinizle içeri almak olur. Aday promosyon niteliğindeyse
280
+ kullanın; değilse dürüst sınırlar platform kotaları ve kendi sessiz saatlerinizdir.
281
+
282
+ Bazı ülkelerin neden burada olmadığı da aynı kapsam sınavıyla açıklanır. Kanada'nın CASL'i ve
283
+ Avustralya'nın 2003 tarihli Spam Act'i rıza, gönderen kimliği ve abonelikten çıkma
284
+ yükümlülükleri getirir; ikisinde de saat kısıtı yoktur. İnternette dolaşan Brezilya penceresi
285
+ PLS 48/2018 sayılı kanun *teklifinden* gelir, yürürlükteki bir kanundan değil, ve
286
+ telefonla pazarlama aramalarını kapsar. Hindistan ilginç olanı: sıkça tekrarlanan "09.00-21.00"
287
+ birincil metinde yazmaz. TRAI düzenlemesi zaman bantlarını, içerik kategorisi ve gün tipiyle
288
+ birlikte, abonenin operatörüne *kaydettirdiği bir tercih* yapar; sabit bir yasal sessizlik
289
+ penceresi değildir. Üstelik pencereyi aktaran ikincil kaynaklar başlangıcın 09.00 mı 10.00 mı
290
+ olduğunda birbiriyle çelişir. Bunun üzerine kurulacak bir paket, hiçbir birincil kaynağın
291
+ yazmadığı bir sayıyı kodlardı; o yüzden yok.
292
+
293
+ ## Adaptörler
294
+
295
+ | alt yol | framework | kapı nerede durur |
296
+ |---|---|---|
297
+ | `proactive-gate/ai-sdk` | Vercel AI SDK | bir aracın `needsApproval` sorusunu yanıtlar (çevrimdışı çalışan örnek: [`examples/ai-sdk/`](examples/ai-sdk/)) |
298
+ | `proactive-gate/mastra` | Mastra | gönderimden önce bir çıktı işlemcisi (çevrimdışı çalışan örnek: [`examples/mastra/`](examples/mastra/)) |
299
+ | `proactive-gate/langchain` | LangChain | gönderim aracının çevresinde middleware |
300
+ | `proactive-gate/openai-agents` | OpenAI Agents | bir guardrail |
301
+ | `npx proactive-gate hook` | Claude Code | bir `PreToolUse` kancası ([`examples/claude-code-hook.json`](examples/claude-code-hook.json)) |
302
+
303
+ Adaptörler framework paketine değil, çağrının biçimine göre tiplenmiştir; başka bir şey
304
+ kurmak gerekmez. Her biri kapının gerekçesiyle reddeder ve onayda bütçeyi tüketir.
305
+
306
+ ## Python
307
+
308
+ ```
309
+ pip install proactive-gate
310
+ ```
311
+
312
+ `python/` sapan bir port değil, bir kardeştir: `spec/fixtures` altındaki her senaryoyu senkron
313
+ `Gate` ve `AsyncGate` (Redis, `redis.asyncio` üzerinden) ile geçer; mypy strict, CI'da Python
314
+ 3.11 ve 3.13. Bkz. [`python/README.md`](python/README.md).
315
+
316
+ ## Sözleşme ve ikinci bir uygulama yazmak
317
+
318
+ [`spec/SPEC.md`](spec/SPEC.md) davranışı numaralı gereksinimler olarak yazar;
319
+ [`spec/fixtures`](spec/fixtures) dile bağlı olmayan senaryoları tutar: America/New_York'taki
320
+ yaz saati kenarı, Pacific/Apia, 2031'de bir duvar saati senaryosu, atomik commit, ISO haftası,
321
+ erteleme, gölge modu, isteğe bağlı kontroller ve dört hazır paket. TypeScript ve Python
322
+ testleri hepsini çalıştırır; `npx proactive-gate replay --fixtures spec/fixtures` komut
323
+ satırından çalıştırır. Üçüncü bir uygulama bu kaynaktan değil, senaryolardan başlar.
324
+
126
325
  ## Bütçe evaluate'te değil, commit'te uygulanır
127
326
 
128
327
  İki örnek aynı kullanıcı için aynı adayı değerlendirebilir, ikisi de beşte dördün kullanıldığını
@@ -206,11 +405,18 @@ yazısında savunulmuş kararlardır. Tian Pan'ın
206
405
  yazısı aynı davayı ürün tarafından savunur ve günde üç ile beş arası bir tavan önerir;
207
406
  `defaultChecks({ dailyLimit })` varsayılanı beştir.
208
407
 
408
+ Biçimin daha eski akrabaları var. Matrix push kuralları, ilk eşleşen kuralın karar verdiği
409
+ sıralı bir listedir. Android bildirim kanalları ve iOS kesinti seviyeleri kullanıcıya tür
410
+ başına bir anahtar ve sessiz saati aşan bir öncelik tabanı verir. Horvitz'in karma girişim
411
+ çalışmaları iki isteğe bağlı kontrolü sağladı. Bu paket o fikirleri izli tek bir listeye
412
+ koyar ve onların dışarıda bıraktığı parçayı ekler: gönderim anında tüketilen bütçe.
413
+
209
414
  ## Geliştirme
210
415
 
211
416
  ```
212
417
  npm ci
213
- npm test # tsc build, then node:test over dist/test
418
+ npm test # tsc build, spec-lint, then node:test over dist/test
419
+ cd python && pytest # the Python sibling against the same fixtures
214
420
  ```
215
421
 
216
422
  MIT.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Vercel AI SDK tool approvals. Give the send tool `needsApproval: true` and
3
+ * answer each approval request through the gate:
4
+ *
5
+ * const approve = gateToolApproval({ gate, toInput: (call) => call.input.gate });
6
+ * const { approved, reason } = await approve(call);
7
+ * // then addToolApprovalResponse({ id: call.approvalId, approved, reason })
8
+ *
9
+ * Typed against the shape of a tool call, not against the `ai` package, so
10
+ * nothing needs to be installed to build proactive-gate.
11
+ */
12
+ import type { Gate } from "../gate.js";
13
+ import type { EvaluateInput } from "../types.js";
14
+ export interface ToolApprovalRequest {
15
+ toolName?: string;
16
+ toolCallId?: string;
17
+ approvalId?: string;
18
+ input?: unknown;
19
+ }
20
+ export interface ToolApprovalResult {
21
+ approved: boolean;
22
+ reason?: string;
23
+ }
24
+ export declare function gateToolApproval<T extends ToolApprovalRequest>(options: {
25
+ gate: Gate;
26
+ toInput: (call: T) => EvaluateInput;
27
+ /** Also consume the budget on approval. Default true. */
28
+ commit?: boolean;
29
+ }): (call: T) => Promise<ToolApprovalResult>;
30
+ export declare function describe(decision: {
31
+ rejectedBy?: string;
32
+ deferredBy?: string;
33
+ retryAt?: Date;
34
+ reason?: string;
35
+ }): string;