proactive-gate 0.2.3 → 0.2.4

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 (41) hide show
  1. package/README.md +63 -0
  2. package/package.json +4 -2
  3. package/spec/CONFORMANCE.md +83 -0
  4. package/spec/SPEC.md +140 -0
  5. package/spec/SPEC_VERSION +1 -0
  6. package/spec/fixtures/adaptive-timing/placeholder.json +58 -0
  7. package/spec/fixtures/budget/bypass-priority.json +100 -0
  8. package/spec/fixtures/budget/daily-atomic-commit.json +140 -0
  9. package/spec/fixtures/budget/near-limit.json +103 -0
  10. package/spec/fixtures/budget/race-second-commit-loses.json +93 -0
  11. package/spec/fixtures/budget/weekly-iso-week.json +134 -0
  12. package/spec/fixtures/consent/required.json +116 -0
  13. package/spec/fixtures/cooldown/three-dismissals.json +132 -0
  14. package/spec/fixtures/dedupe/already-delivered.json +87 -0
  15. package/spec/fixtures/dedupe/no-key-skips.json +49 -0
  16. package/spec/fixtures/defer/snooze-as-defer.json +138 -0
  17. package/spec/fixtures/mode/allow-list.json +139 -0
  18. package/spec/fixtures/ordering/kill-switch.json +56 -0
  19. package/spec/fixtures/ordering/short-circuit.json +235 -0
  20. package/spec/fixtures/policy/unknown-check-is-an-error.json +50 -0
  21. package/spec/fixtures/presets/cn-minor-mode.json +170 -0
  22. package/spec/fixtures/presets/kakao-brand-message.json +93 -0
  23. package/spec/fixtures/presets/kr-network-act-50.json +168 -0
  24. package/spec/fixtures/presets/telegram-bot.json +162 -0
  25. package/spec/fixtures/presets/us-tcpa.json +90 -0
  26. package/spec/fixtures/quiet-hours/apia.json +90 -0
  27. package/spec/fixtures/quiet-hours/caller-supplied-dates.json +197 -0
  28. package/spec/fixtures/quiet-hours/crosses-midnight-by-day.json +221 -0
  29. package/spec/fixtures/quiet-hours/dst-new-york.json +126 -0
  30. package/spec/fixtures/quiet-hours/istanbul.json +160 -0
  31. package/spec/fixtures/quiet-hours/wall-clock.json +94 -0
  32. package/spec/fixtures/quiet-hours/weekday-schedule.json +262 -0
  33. package/spec/fixtures/shadow/reject-continues.json +143 -0
  34. package/spec/fixtures/trust-ramp/first-week.json +154 -0
  35. package/spec/fixtures/utility/bounded-deferral-cap.json +60 -0
  36. package/spec/fixtures/utility/bounded-deferral.json +95 -0
  37. package/spec/fixtures/utility/floor.json +168 -0
  38. package/spec/schema/fixture.schema.json +58 -0
  39. package/spec/schema/policy.schema.json +23 -0
  40. package/spec/skip/python.txt +0 -0
  41. package/spec/skip/ts.txt +0 -0
package/README.md CHANGED
@@ -595,6 +595,69 @@ Python tests both run all of them; `npx proactive-gate replay --fixtures spec/fi
595
595
  them from the command line. A third implementation starts from the fixtures, not from this
596
596
  source.
597
597
 
598
+ The suite is an artifact, not a folder in this package. It is versioned by
599
+ [`spec/SPEC_VERSION`](spec/SPEC_VERSION) and tagged `spec/vX.Y.Z`, a series separate from the
600
+ package's release tags, so an implementation in any language can pin it without depending on npm
601
+ or PyPI:
602
+
603
+ ```sh
604
+ git clone --depth 1 --branch spec/v1.2.0 https://github.com/Bubblegunn/proactive-gate
605
+ ```
606
+
607
+ The npm package also ships it, so `node_modules/proactive-gate/spec/fixtures` exists after an
608
+ install. [`spec/CONFORMANCE.md`](spec/CONFORMANCE.md) states what passing means field by field,
609
+ and how to declare a skip: silence about a failing fixture is the one thing that makes a
610
+ conformance claim worthless.
611
+
612
+ <!-- conformance:start -->
613
+ Generated by `npm run conformance-table`; CI fails when it is stale.
614
+
615
+ | implementation | spec version | fixtures passed | declared skips |
616
+ |---|---|---:|---|
617
+ | TypeScript | 1.2.0 | 32 of 32 | none |
618
+ | Python | 1.2.0 | 32 of 32 | none |
619
+ <!-- conformance:end -->
620
+
621
+ ### What made this work elsewhere, and why it might not here
622
+
623
+ The [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) is the
624
+ working example of a language-neutral fixture set becoming common ground: JSON files, a directory
625
+ per draft, consumed as "a git submodule or git subtree", an `optional/` directory for cases
626
+ implementations may decline, and validators in more than twenty languages held to it. What made it
627
+ work is that people were already implementing JSON Schema and already disagreeing about edge cases.
628
+ The suite settled arguments that existed.
629
+
630
+ This suite has no such argument to settle. Almost nobody has implemented notification gating twice,
631
+ so there is no disagreement waiting for a referee, and being early to a contract nobody adopts is
632
+ indistinguishable from being wrong. What the suite is worth today is narrower and still worth
633
+ having: it is why the Python package behaves like the TypeScript one, and it is what a third
634
+ implementation would be measured against rather than argued with.
635
+
636
+ Two implementations pass it and the same person wrote both, hours apart. That is a consistency
637
+ check, not independent verification, and the honest test is a third implementation written from
638
+ `SPEC.md` by someone who has not read this source. The method is in
639
+ [`docs/superpowers/specs/2026-09-05-proactive-gate-conformance-design.md`](docs/superpowers/specs/2026-09-05-proactive-gate-conformance-design.md).
640
+
641
+ ### Where this sits next to MCP and A2A
642
+
643
+ Neither protocol answers the question this library answers, and both were read at the source rather
644
+ than summarised.
645
+
646
+ MCP's [elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation) is
647
+ the closest mechanism and it is complementary. It "provides a standardized way for servers to
648
+ request additional information from users through the client during interactions", and those
649
+ requests "occur *nested* inside other MCP server features". The user already started something and
650
+ the server needs input to finish it. Nothing there concerns being approached by an agent that
651
+ nobody asked: no quiet hours, no budget, no dismissal cooldown, no consent to be contacted.
652
+
653
+ A2A's [push notifications](https://a2a-protocol.org/latest/specification/) are transport. The
654
+ specification scopes them to server-to-server integrations, long-running tasks and event-driven
655
+ architectures, delivered by HTTP POST to client-registered webhook endpoints. Quiet hours, rate
656
+ limits and notification budgets do not appear in it.
657
+
658
+ Both answer how a message moves. Neither answers whether it should be sent now. That is the whole
659
+ claim, and it is all the reading supports.
660
+
598
661
  ## Performance
599
662
 
600
663
  `npm run bench` runs `gate.evaluate()` ten thousand times with the default twelve checks and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
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",
@@ -37,6 +37,7 @@
37
37
  },
38
38
  "files": [
39
39
  "dist/src",
40
+ "spec",
40
41
  "README.md",
41
42
  "LICENSE"
42
43
  ],
@@ -53,7 +54,8 @@
53
54
  "release": "node scripts/release.mjs",
54
55
  "release-gate": "npm run build && node scripts/release-gate.mjs",
55
56
  "trace-svg": "npm run build && node scripts/trace-svg.mjs",
56
- "bench:compare": "npm run build && node bench/compare.mjs"
57
+ "bench:compare": "npm run build && node bench/compare.mjs",
58
+ "conformance-table": "node scripts/conformance-table.mjs"
57
59
  },
58
60
  "engines": {
59
61
  "node": ">=20"
@@ -0,0 +1,83 @@
1
+ # Claiming conformance
2
+
3
+ This directory is the contract. `SPEC.md` states the behaviour as numbered requirements and
4
+ `fixtures/` holds the cases that decide whether an implementation meets them. Both are
5
+ language-neutral: an implementation in any language can run them, and none of it depends on the
6
+ npm or PyPI packages.
7
+
8
+ ## Getting the suite
9
+
10
+ The suite is versioned by `SPEC_VERSION`, and each version is tagged `spec/vX.Y.Z`, a series
11
+ separate from the package's own `vX.Y.Z` release tags.
12
+
13
+ ```sh
14
+ git clone --depth 1 --branch spec/v1.2.0 https://github.com/Bubblegunn/proactive-gate
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
17
+ ```
18
+
19
+ A JavaScript implementation can also read the fixtures from an install, because the npm package
20
+ ships this directory: `node_modules/proactive-gate/spec/fixtures`. The Python wheel does not ship
21
+ it; use git there.
22
+
23
+ ## What passing means
24
+
25
+ An implementation conforms at version X when, for every fixture whose `spec_version` is X, every
26
+ assertion in every test's `expect` holds.
27
+
28
+ A fixture is a JSON document described by `schema/fixture.schema.json`. For each test, evaluate the
29
+ policy against the input at the given `now` and compare:
30
+
31
+ | Field | Comparison |
32
+ |---|---|
33
+ | `allowed` | exact |
34
+ | `trace` | exact, the ordered list of check ids that ran |
35
+ | `rejectedBy`, `deferredBy` | exact, including absent |
36
+ | `retryAt`, `deliverAt` | exact, as an ISO instant ending `Z` |
37
+ | `surfaces`, `shadowed`, `nearLimit` | exact, when the fixture asserts them |
38
+ | `reason_pattern` | a regular expression that must match the decision's reason |
39
+ | `commit` | the boolean returned by committing the decision, when the test sets `commit` |
40
+ | `store_after` | exact, each key read from the store after the test, with the policy's key prefix |
41
+
42
+ `ms` on a trace entry is informative and is never asserted (`SPEC.md` 8.1). A fixture's
43
+ `store_seed` is written to the store before the tests run, with the same prefix.
44
+
45
+ Both existing runners work exactly this way, so this table describes the suite rather than adding a
46
+ second rule to it: see `src/conformance.ts` and `python/src/proactive_gate/conformance.py`.
47
+
48
+ ## Declaring what you skip
49
+
50
+ Silence about a failing fixture is the one thing that makes a conformance claim worthless. Declare
51
+ skips in `skip/<impl>.txt`, one fixture name per line, with the reason after a `#`:
52
+
53
+ ```
54
+ quiet-hours/apia # no IANA time zone database on this platform
55
+ ```
56
+
57
+ `SPEC.md` requires that file to be empty at a stable release. Before then, the honest form of a
58
+ partial claim is "conforms to 1.2.0 except these fixtures, for these reasons", stated where a
59
+ reader will see it.
60
+
61
+ ## Declaring the version you target
62
+
63
+ State the spec version in your own metadata, and assert in your continuous integration that it
64
+ equals the `SPEC_VERSION` in the suite you vendored. Both implementations here do that, and it is
65
+ what stops a suite from being updated underneath a claim.
66
+
67
+ ## Adding to the suite
68
+
69
+ A fixture is a contract for every implementation, not only this one. So a change lands in
70
+ `SPEC.md`, in `fixtures/`, and in both implementations, or it does not land. New fixtures carry
71
+ `since` set to the version that introduced them, and `spec_version` set to the current one, which
72
+ `test/spec-lint.mjs` checks.
73
+
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.
76
+
77
+ ## The honest status of this suite
78
+
79
+ Two implementations pass it, and the same person wrote both within hours of each other. That is
80
+ weaker evidence than it looks: agreement between two implementations by one author is closer to a
81
+ consistency check than to independent verification. A third implementation, written from `SPEC.md`
82
+ by someone who has not read the source, is what would test whether this document is enough. Until
83
+ that exists, treat the suite as a contract that has been used twice, not as a proven standard.
package/spec/SPEC.md ADDED
@@ -0,0 +1,140 @@
1
+ # proactive-gate behaviour contract
2
+
3
+ Version: see `SPEC_VERSION`. The key words MUST, MUST NOT, SHOULD and MAY are to be read as in
4
+ RFC 2119. An implementation conforms when it passes every fixture under `fixtures/` for its
5
+ declared spec version, minus the fixtures listed in its `skip/<impl>.txt` file, which MUST be
6
+ empty at a stable release.
7
+
8
+ Versioning: patch releases add fixtures that existing implementations already pass; minor
9
+ releases add a check or field and mark it with `since`; major releases change an expectation.
10
+ An implementation declares the spec version it targets, and its CI MUST assert that the value
11
+ equals `SPEC_VERSION`.
12
+
13
+ ## 1. Inputs
14
+
15
+ 1.1 An evaluation input is a user, a candidate and an instant `now`. `now` MUST be supplied by
16
+ the caller in fixtures and MAY default to the current instant in library use.
17
+
18
+ 1.2 A user has at least `id` and `consent`. Optional fields: `proactiveEnabled`, `mode`,
19
+ `snoozedUntil`, `mutedTypes`, `intensity` (low, normal, high), `timezone` (IANA), `quietHours`
20
+ (a window, or a schedule; see 6.3), `createdAt`, `surfaces`, `consents` (map of
21
+ name to boolean), `lastInboundAt`, `minor`, `existingCustomer`.
22
+
23
+ 1.3 A candidate has at least `id` and `type`. Optional: `priority` (low, normal, high,
24
+ critical; default normal), `surfaces`, `channel`, `busy`, `pAccept`, `pNeed`, `payload`.
25
+
26
+ 1.4 An implementation MUST NOT read `payload`.
27
+
28
+ ## 2. Evaluation order and short circuit
29
+
30
+ 2.1 An implementation MUST run checks in policy order and MUST stop at the first check whose
31
+ outcome is `reject` or `defer` and which is not in shadow mode.
32
+
33
+ 2.2 The trace MUST list every check that ran, in order, with its outcome kind. Checks after the
34
+ stopping check MUST NOT appear.
35
+
36
+ 2.3 The surfaces of an allowed decision start as the candidate's surfaces (default `["feed"]`)
37
+ filtered by the user's allowed surfaces when the user lists any, and MAY be narrowed by
38
+ `adjust` outcomes.
39
+
40
+ ## 3. Outcomes
41
+
42
+ 3.1 A check returns exactly one of `pass`, `reject` (with a reason), `adjust` (reason, optional
43
+ `deliverAt`, optional `surfaces`), `skip` (reason), or `defer` (reason and `retryAt`).
44
+
45
+ 3.2 A check marked non-rejecting that returns `reject` MUST be recorded as `skip` and MUST NOT
46
+ stop evaluation.
47
+
48
+ 3.3 `defer` produces a decision with `allowed` false, `deferredBy` set to the check id and
49
+ `retryAt` set to the instant the check supplied. `rejectedBy` MUST be absent.
50
+
51
+ 3.4 A `pass` MAY carry `nearLimit` with `used` and `limit`; the decision lists every such entry
52
+ in order.
53
+
54
+ 3.5 A check that throws MUST be recorded as `skip` and evaluation continues when the gate fails
55
+ open, or as `reject` and evaluation stops when it fails closed. The default is open.
56
+
57
+ ## 4. Shadow mode
58
+
59
+ 4.1 A check with `shadow` true that returns `reject` or `defer` MUST be recorded in the trace
60
+ with its real outcome kind and `shadow` true, its id MUST be appended to `shadowed`, and
61
+ evaluation MUST continue as if it had passed.
62
+
63
+ ## 5. Store keys and atomic commit
64
+
65
+ 5.1 Keys, before the implementation's prefix (default `pg:`):
66
+ `budget:<userId>:<YYYY-MM-DD>` local day, `weeklyBudget:<userId>:<YYYY>-W<WW>` ISO week of the
67
+ local day, `monthlyBudget:<userId>:<YYYY-MM>`, `cooldown:<userId>:<type>` (JSON array of epoch
68
+ milliseconds), `rate:<scope>:<window>` for rate limits, `windowBudget:<userId>:<epochSeconds of
69
+ lastInboundAt>`, `commit:<decisionId>`.
70
+
71
+ 5.2 Budget checks read the counter at evaluate and MUST NOT increment it. `commit` MUST
72
+ increment atomically, in check order, and return false when a counter exceeds its limit.
73
+
74
+ 5.3 `commit` MUST be idempotent on the decision id: a second call returns the first result
75
+ without incrementing.
76
+
77
+ 5.4 `commit` on a decision that is not allowed MUST return false without touching the store.
78
+
79
+ 5.5 `dedupe` keys as `dedupe:<userId>:<candidate.dedupeKey>`. With no `dedupeKey` on the
80
+ candidate it MUST skip, not pass silently: a deduplication keyed on something unique per
81
+ attempt does nothing, and an implementation that guessed a key would hide that.
82
+
83
+ 5.6 `dedupe` MUST NOT claim at evaluate. It reads the key at evaluate and rejects when the key
84
+ is present; it claims at commit with the same atomic increment the budgets use, and only the
85
+ caller receiving the first increment may deliver. Two callers evaluating the same event
86
+ concurrently therefore both pass the check and exactly one commit succeeds. A read-then-write
87
+ claim is non-conforming.
88
+
89
+ 5.7 Where `dedupe` and a budget are both present, `dedupe` MUST consume first, so a suppressed
90
+ duplicate does not spend a budget unit. The consequence, which implementations MUST NOT hide:
91
+ an event that clears `dedupe` and is then refused by an exhausted budget has claimed its key
92
+ for the remainder of the window.
93
+
94
+ 5.8 The deduplication window is fixed from the first claim, not sliding. Incrementing an
95
+ existing key MUST NOT extend its expiry.
96
+
97
+ ## 6. Clock and time zones
98
+
99
+ 6.1 `now` is an instant. Local day, minutes and ISO week are derived from `now` in the user's
100
+ IANA zone; without a zone, UTC.
101
+
102
+ 6.2 A check MUST NOT read a wall clock. Fixtures with `now` far in the future only pass when
103
+ `now` is honoured.
104
+
105
+ 6.3 Quiet hours use `[start, end)` and may cross midnight; `start == end` is an empty window.
106
+
107
+ 6.4 `quietHours` is either a window (`start`, `end` as `HH:MM`) or a schedule (since 1.1.0) with
108
+ optional `default` (a window or null), `days` (a map of `sun` to `sat` to a window or null) and
109
+ `dates` (a map of `YYYY-MM-DD` in the user's zone to a window or null). A window applies on every
110
+ day; a schedule resolves one window per local date, and an implementation MUST resolve it as
111
+ `dates[date]`, else `days[weekday(date)]`, else `default`, else none, where a present key whose
112
+ value is null means the day has no quiet hours.
113
+
114
+ 6.5 A window belongs to the day it opens on. An implementation MUST treat a local time as quiet
115
+ when the window resolved for that local date contains it, or when the window resolved for the
116
+ previous local date crosses midnight and the time is before its `end`. The day resolved for the
117
+ current date takes precedence when both apply. A schedule whose every day resolves to the same
118
+ window MUST behave identically to that window given directly.
119
+
120
+ 6.6 The weekday of a local date MUST be derived from the local calendar date, not from an
121
+ instant, so that a zone with an offset that is not a whole hour and a daylight-saving transition
122
+ cannot change it.
123
+
124
+ 6.7 An implementation MUST NOT ship a calendar of holidays. `dates` is supplied by the caller.
125
+
126
+ ## 7. Policy document
127
+
128
+ 7.1 A policy is JSON with `specVersion`, optional `onStoreError`, optional `keyPrefix` and an
129
+ ordered `checks` array. An entry is `{ "id": <check>, ...options, "shadow"?: bool }` or
130
+ `{ "preset": <name>, ...options, "shadow"?: bool }`.
131
+
132
+ 7.2 A preset entry expands in place to the preset's ordered checks.
133
+
134
+ 7.3 An unknown check id or preset name MUST be rejected when the policy is compiled, naming the
135
+ known ids.
136
+
137
+ ## 8. Trace
138
+
139
+ 8.1 Each trace entry has `id`, `outcome`, optional `reason`, `ms`, optional `shadow`. `ms` is
140
+ informative and MUST NOT appear in fixtures.
@@ -0,0 +1 @@
1
+ 1.2.0
@@ -0,0 +1,58 @@
1
+ {
2
+ "spec_version": "1.2.0",
3
+ "since": "1.0.0",
4
+ "name": "adaptive-timing/placeholder",
5
+ "description": "A JSON policy cannot carry a next-good-moment function; the placeholder passes and keeps the trace shape.",
6
+ "policy": {
7
+ "specVersion": "1.0.0",
8
+ "checks": [
9
+ {
10
+ "id": "consent"
11
+ },
12
+ {
13
+ "id": "adaptiveTiming"
14
+ },
15
+ {
16
+ "id": "dailyBudget"
17
+ }
18
+ ]
19
+ },
20
+ "tests": [
21
+ {
22
+ "description": "passes",
23
+ "input": {
24
+ "user": {
25
+ "id": "u1",
26
+ "consent": true,
27
+ "proactiveEnabled": true,
28
+ "mode": "normal",
29
+ "intensity": "normal",
30
+ "timezone": "Europe/Istanbul",
31
+ "quietHours": {
32
+ "start": "22:00",
33
+ "end": "08:00"
34
+ },
35
+ "createdAt": "2026-01-01T00:00:00Z"
36
+ },
37
+ "candidate": {
38
+ "id": "c1",
39
+ "type": "reminder",
40
+ "priority": "normal",
41
+ "surfaces": [
42
+ "push",
43
+ "feed"
44
+ ]
45
+ },
46
+ "now": "2026-09-04T09:00:00Z"
47
+ },
48
+ "expect": {
49
+ "allowed": true,
50
+ "trace": [
51
+ "consent",
52
+ "adaptiveTiming",
53
+ "dailyBudget"
54
+ ]
55
+ }
56
+ }
57
+ ]
58
+ }
@@ -0,0 +1,100 @@
1
+ {
2
+ "spec_version": "1.2.0",
3
+ "since": "1.0.0",
4
+ "name": "budget/bypass-priority",
5
+ "description": "A bypass priority passes the budget without reading or consuming it.",
6
+ "policy": {
7
+ "specVersion": "1.0.0",
8
+ "checks": [
9
+ {
10
+ "id": "consent"
11
+ },
12
+ {
13
+ "id": "dailyBudget",
14
+ "limit": 1,
15
+ "bypassPriority": "critical"
16
+ }
17
+ ]
18
+ },
19
+ "store_seed": {
20
+ "budget:u1:2026-09-04": "1"
21
+ },
22
+ "tests": [
23
+ {
24
+ "description": "critical passes a full budget and commit consumes nothing",
25
+ "input": {
26
+ "user": {
27
+ "id": "u1",
28
+ "consent": true,
29
+ "proactiveEnabled": true,
30
+ "mode": "normal",
31
+ "intensity": "normal",
32
+ "timezone": "Europe/Istanbul",
33
+ "quietHours": {
34
+ "start": "22:00",
35
+ "end": "08:00"
36
+ },
37
+ "createdAt": "2026-01-01T00:00:00Z"
38
+ },
39
+ "candidate": {
40
+ "id": "c1",
41
+ "type": "reminder",
42
+ "priority": "critical",
43
+ "surfaces": [
44
+ "push",
45
+ "feed"
46
+ ]
47
+ },
48
+ "now": "2026-09-04T09:00:00Z"
49
+ },
50
+ "commit": true,
51
+ "expect": {
52
+ "allowed": true,
53
+ "trace": [
54
+ "consent",
55
+ "dailyBudget"
56
+ ],
57
+ "commit": true,
58
+ "store_after": {
59
+ "budget:u1:2026-09-04": "1"
60
+ }
61
+ }
62
+ },
63
+ {
64
+ "description": "normal is rejected",
65
+ "input": {
66
+ "user": {
67
+ "id": "u1",
68
+ "consent": true,
69
+ "proactiveEnabled": true,
70
+ "mode": "normal",
71
+ "intensity": "normal",
72
+ "timezone": "Europe/Istanbul",
73
+ "quietHours": {
74
+ "start": "22:00",
75
+ "end": "08:00"
76
+ },
77
+ "createdAt": "2026-01-01T00:00:00Z"
78
+ },
79
+ "candidate": {
80
+ "id": "c2",
81
+ "type": "reminder",
82
+ "priority": "normal",
83
+ "surfaces": [
84
+ "push",
85
+ "feed"
86
+ ]
87
+ },
88
+ "now": "2026-09-04T09:00:00Z"
89
+ },
90
+ "expect": {
91
+ "allowed": false,
92
+ "rejectedBy": "dailyBudget",
93
+ "trace": [
94
+ "consent",
95
+ "dailyBudget"
96
+ ]
97
+ }
98
+ }
99
+ ]
100
+ }
@@ -0,0 +1,140 @@
1
+ {
2
+ "spec_version": "1.2.0",
3
+ "since": "1.0.0",
4
+ "name": "budget/daily-atomic-commit",
5
+ "description": "evaluate reads the counter, commit consumes it, the extra commit is refused.",
6
+ "policy": {
7
+ "specVersion": "1.0.0",
8
+ "checks": [
9
+ {
10
+ "id": "consent"
11
+ },
12
+ {
13
+ "id": "dailyBudget",
14
+ "limit": 2
15
+ }
16
+ ]
17
+ },
18
+ "store_seed": {
19
+ "budget:u1:2026-09-04": "1"
20
+ },
21
+ "tests": [
22
+ {
23
+ "description": "one used, one left",
24
+ "input": {
25
+ "user": {
26
+ "id": "u1",
27
+ "consent": true,
28
+ "proactiveEnabled": true,
29
+ "mode": "normal",
30
+ "intensity": "normal",
31
+ "timezone": "Europe/Istanbul",
32
+ "quietHours": {
33
+ "start": "22:00",
34
+ "end": "08:00"
35
+ },
36
+ "createdAt": "2026-01-01T00:00:00Z"
37
+ },
38
+ "candidate": {
39
+ "id": "c1",
40
+ "type": "reminder",
41
+ "priority": "normal",
42
+ "surfaces": [
43
+ "push",
44
+ "feed"
45
+ ]
46
+ },
47
+ "now": "2026-09-04T09:00:00Z"
48
+ },
49
+ "commit": true,
50
+ "expect": {
51
+ "allowed": true,
52
+ "trace": [
53
+ "consent",
54
+ "dailyBudget"
55
+ ],
56
+ "commit": true,
57
+ "store_after": {
58
+ "budget:u1:2026-09-04": "2"
59
+ }
60
+ }
61
+ },
62
+ {
63
+ "description": "two used: rejected at evaluate",
64
+ "input": {
65
+ "user": {
66
+ "id": "u1",
67
+ "consent": true,
68
+ "proactiveEnabled": true,
69
+ "mode": "normal",
70
+ "intensity": "normal",
71
+ "timezone": "Europe/Istanbul",
72
+ "quietHours": {
73
+ "start": "22:00",
74
+ "end": "08:00"
75
+ },
76
+ "createdAt": "2026-01-01T00:00:00Z"
77
+ },
78
+ "candidate": {
79
+ "id": "c2",
80
+ "type": "reminder",
81
+ "priority": "normal",
82
+ "surfaces": [
83
+ "push",
84
+ "feed"
85
+ ]
86
+ },
87
+ "now": "2026-09-04T09:00:00Z"
88
+ },
89
+ "expect": {
90
+ "allowed": false,
91
+ "rejectedBy": "dailyBudget",
92
+ "trace": [
93
+ "consent",
94
+ "dailyBudget"
95
+ ],
96
+ "reason_pattern": "2 used \\(2\\)"
97
+ }
98
+ },
99
+ {
100
+ "description": "next local day resets (00:30 Istanbul)",
101
+ "input": {
102
+ "user": {
103
+ "id": "u1",
104
+ "consent": true,
105
+ "proactiveEnabled": true,
106
+ "mode": "normal",
107
+ "intensity": "normal",
108
+ "timezone": "Europe/Istanbul",
109
+ "quietHours": {
110
+ "start": "22:00",
111
+ "end": "08:00"
112
+ },
113
+ "createdAt": "2026-01-01T00:00:00Z"
114
+ },
115
+ "candidate": {
116
+ "id": "c3",
117
+ "type": "reminder",
118
+ "priority": "normal",
119
+ "surfaces": [
120
+ "push",
121
+ "feed"
122
+ ]
123
+ },
124
+ "now": "2026-09-04T21:30:00Z"
125
+ },
126
+ "commit": true,
127
+ "expect": {
128
+ "allowed": true,
129
+ "trace": [
130
+ "consent",
131
+ "dailyBudget"
132
+ ],
133
+ "commit": true,
134
+ "store_after": {
135
+ "budget:u1:2026-09-05": "1"
136
+ }
137
+ }
138
+ }
139
+ ]
140
+ }