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.
@@ -1,261 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtempSync, rmSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { createGate, MemoryStore, SqliteStore, defaultChecks, checks, localClock, inWindow } from "../src/index.js";
7
- import { replay, summarize } from "../src/cli.js";
8
- const user = (overrides = {}) => ({
9
- id: "u1",
10
- consent: true,
11
- proactiveEnabled: true,
12
- mode: "normal",
13
- intensity: "normal",
14
- timezone: "Europe/Istanbul",
15
- quietHours: { start: "22:00", end: "08:00" },
16
- createdAt: "2026-01-01T00:00:00Z",
17
- ...overrides,
18
- });
19
- const candidate = (overrides = {}) => ({ id: "c1", type: "reminder", priority: "normal", surfaces: ["push", "feed"], ...overrides });
20
- const noon = new Date("2026-09-04T09:00:00Z"); // 12:00 in Istanbul (UTC+3)
21
- const night = new Date("2026-09-04T20:30:00Z"); // 23:30 in Istanbul
22
- test("happy path: every check passes, surfaces are returned, trace lists all twelve", async () => {
23
- const gate = createGate({ checks: defaultChecks() });
24
- const d = await gate.evaluate({ user: user(), candidate: candidate(), now: noon });
25
- assert.equal(d.allowed, true);
26
- assert.deepEqual(d.surfaces, ["push", "feed"]);
27
- assert.equal(d.trace.length, 12);
28
- assert.deepEqual(d.trace.map((t) => t.id), ["killSwitch", "consent", "enabled", "mode", "snooze", "mute", "intensity", "quietHours", "trustRamp", "dismissalCooldown", "adaptiveTiming", "dailyBudget"]);
29
- });
30
- test("order is visible: consent rejects before quiet hours gets a chance", async () => {
31
- const gate = createGate({ checks: defaultChecks() });
32
- const d = await gate.evaluate({ user: user({ consent: false }), candidate: candidate(), now: night });
33
- assert.equal(d.allowed, false);
34
- assert.equal(d.rejectedBy, "consent");
35
- assert.equal(d.trace.length, 2);
36
- });
37
- test("kill switch silences everything and says so", async () => {
38
- const gate = createGate({ checks: defaultChecks({ killSwitch: () => true }) });
39
- const d = await gate.evaluate({ user: user(), candidate: candidate({ priority: "critical" }), now: noon });
40
- assert.equal(d.rejectedBy, "killSwitch");
41
- assert.match(d.reason, /kill switch/);
42
- });
43
- test("quiet hours: rejects at 23:30 local, passes at noon, crosses midnight, bypassed at the floor", async () => {
44
- const gate = createGate({ checks: [checks.quietHours({ priorityFloor: "high" })] });
45
- const late = await gate.evaluate({ user: user(), candidate: candidate(), now: night });
46
- assert.equal(late.rejectedBy, "quietHours");
47
- assert.match(late.reason, /22:00 to 08:00 Europe\/Istanbul/);
48
- const early = await gate.evaluate({ user: user(), candidate: candidate(), now: new Date("2026-09-05T03:30:00Z") }); // 06:30 local
49
- assert.equal(early.allowed, false);
50
- const day = await gate.evaluate({ user: user(), candidate: candidate(), now: noon });
51
- assert.equal(day.allowed, true);
52
- const urgent = await gate.evaluate({ user: user(), candidate: candidate({ priority: "high" }), now: night });
53
- assert.equal(urgent.allowed, true);
54
- const noTz = await gate.evaluate({ user: user({ timezone: undefined }), candidate: candidate(), now: night });
55
- assert.equal(noTz.allowed, true);
56
- assert.equal(noTz.trace[0]?.outcome, "skip");
57
- });
58
- test("localClock and inWindow handle zones and midnight", () => {
59
- assert.equal(localClock(new Date("2026-09-04T20:30:00Z"), "Europe/Istanbul").minutes, 23 * 60 + 30);
60
- assert.equal(localClock(new Date("2026-09-04T20:30:00Z"), "America/Los_Angeles").minutes, 13 * 60 + 30);
61
- assert.equal(localClock(new Date("2026-09-04T23:30:00Z"), "Europe/Istanbul").day, "2026-09-05");
62
- assert.equal(inWindow(23 * 60, 22 * 60, 8 * 60), true);
63
- assert.equal(inWindow(7 * 60, 22 * 60, 8 * 60), true);
64
- assert.equal(inWindow(12 * 60, 22 * 60, 8 * 60), false);
65
- assert.equal(inWindow(12 * 60, 9 * 60, 17 * 60), true);
66
- assert.equal(inWindow(12 * 60, 12 * 60, 12 * 60), false);
67
- });
68
- test("trust ramp: new users hear only high priority for seven days", async () => {
69
- const gate = createGate({ checks: [checks.trustRamp()] });
70
- const fresh = user({ createdAt: "2026-09-02T00:00:00Z" });
71
- const normal = await gate.evaluate({ user: fresh, candidate: candidate(), now: noon });
72
- assert.equal(normal.rejectedBy, "trustRamp");
73
- assert.match(normal.reason, /day 3 of 7/);
74
- const high = await gate.evaluate({ user: fresh, candidate: candidate({ priority: "high" }), now: noon });
75
- assert.equal(high.allowed, true);
76
- const old = await gate.evaluate({ user: user(), candidate: candidate(), now: noon });
77
- assert.equal(old.allowed, true);
78
- });
79
- test("intensity maps to a priority floor", async () => {
80
- const gate = createGate({ checks: [checks.intensity()] });
81
- assert.equal((await gate.evaluate({ user: user({ intensity: "low" }), candidate: candidate(), now: noon })).rejectedBy, "intensity");
82
- assert.equal((await gate.evaluate({ user: user({ intensity: "low" }), candidate: candidate({ priority: "high" }), now: noon })).allowed, true);
83
- assert.equal((await gate.evaluate({ user: user({ intensity: "high" }), candidate: candidate({ priority: "low" }), now: noon })).allowed, true);
84
- });
85
- test("snooze, mute, mode and enabled each reject with a specific reason", async () => {
86
- const gate = createGate({ checks: defaultChecks() });
87
- const cases = [
88
- [{ snoozedUntil: "2026-09-04T12:00:00Z" }, "snooze"],
89
- [{ mutedTypes: ["reminder"] }, "mute"],
90
- [{ mode: "focus" }, "mode"],
91
- [{ proactiveEnabled: false }, "enabled"],
92
- ];
93
- for (const [overrides, expected] of cases) {
94
- const d = await gate.evaluate({ user: user(overrides), candidate: candidate(), now: noon });
95
- assert.equal(d.rejectedBy, expected, `expected ${expected} to reject`);
96
- }
97
- });
98
- test("dismissal cooldown: three dismissals in thirty days buy a week of silence for that type only", async () => {
99
- const store = new MemoryStore();
100
- const gate = createGate({ store, checks: [checks.dismissalCooldown()] });
101
- const u = user();
102
- for (const day of [1, 2, 3])
103
- await gate.record(u, { type: "reminder" }, "dismissed", new Date(`2026-09-0${day}T10:00:00Z`));
104
- const silenced = await gate.evaluate({ user: u, candidate: candidate(), now: new Date("2026-09-05T10:00:00Z") });
105
- assert.equal(silenced.rejectedBy, "dismissalCooldown");
106
- assert.match(silenced.reason, /silent until 2026-09-10T10:00:00.000Z/);
107
- const otherType = await gate.evaluate({ user: u, candidate: candidate({ type: "insight" }), now: new Date("2026-09-05T10:00:00Z") });
108
- assert.equal(otherType.allowed, true);
109
- const later = await gate.evaluate({ user: u, candidate: candidate(), now: new Date("2026-09-11T10:00:00Z") });
110
- assert.equal(later.allowed, true);
111
- await gate.record(u, { type: "reminder" }, "acted");
112
- const acted = await gate.evaluate({ user: u, candidate: candidate(), now: new Date("2026-09-11T10:00:00Z") });
113
- assert.equal(acted.allowed, true);
114
- });
115
- test("daily budget: evaluate reads, commit consumes atomically and refuses the sixth delivery", async () => {
116
- const store = new MemoryStore();
117
- const gate = createGate({ store, checks: [checks.dailyBudget({ limit: 5 })] });
118
- const input = { user: user(), candidate: candidate(), now: noon };
119
- for (let i = 0; i < 5; i++) {
120
- const d = await gate.evaluate(input);
121
- assert.equal(d.allowed, true, `delivery ${i + 1} should be allowed`);
122
- assert.equal(await gate.commit(d, input), true);
123
- }
124
- const sixth = await gate.evaluate(input);
125
- assert.equal(sixth.rejectedBy, "dailyBudget");
126
- assert.match(sixth.reason, /5 used \(5\)/);
127
- // Two instances that both evaluated before either committed: only one wins.
128
- const store2 = new MemoryStore();
129
- const gate2 = createGate({ store: store2, checks: [checks.dailyBudget({ limit: 1 })] });
130
- const a = await gate2.evaluate(input);
131
- const b = await gate2.evaluate(input);
132
- assert.equal(a.allowed && b.allowed, true);
133
- assert.equal(await gate2.commit(a, input), true);
134
- assert.equal(await gate2.commit(b, input), false);
135
- // The budget resets on the user's local day, not UTC.
136
- const nextLocalDay = new Date("2026-09-04T21:30:00Z"); // 00:30 next day in Istanbul
137
- assert.equal((await gate.evaluate({ ...input, now: nextLocalDay })).allowed, true);
138
- assert.equal((await gate.inspect(user(), noon)).budgetUsed, 5);
139
- });
140
- test("weekly budget: resets on the user's local ISO week and commits atomically", async () => {
141
- const store = new MemoryStore();
142
- const gate = createGate({ store, checks: [checks.weeklyBudget({ limit: 2 })] });
143
- const input = { user: user(), candidate: candidate(), now: new Date("2026-09-04T09:00:00Z") };
144
- const first = await gate.evaluate(input);
145
- const second = await gate.evaluate(input);
146
- assert.equal(await gate.commit(first, input), true);
147
- assert.equal(await gate.commit(second, input), true);
148
- assert.equal((await gate.evaluate(input)).rejectedBy, "weeklyBudget");
149
- const nextWeek = await gate.evaluate({ ...input, now: new Date("2026-09-07T09:00:00Z") });
150
- assert.equal(nextWeek.allowed, true);
151
- });
152
- const sqliteAvailable = Number(process.versions.node.split(".")[0]) >= 22;
153
- test("sqlite store supports get, set, increment, delete and expiration", { skip: !sqliteAvailable }, async () => {
154
- let now = 1_000_000;
155
- const store = new SqliteStore(":memory:", () => now);
156
- assert.equal(await store.get("missing"), null);
157
- await store.set("key", "value");
158
- assert.equal(await store.get("key"), "value");
159
- assert.equal(await store.incr("counter"), 1);
160
- assert.equal(await store.incr("counter", 10), 2);
161
- assert.equal(await store.get("counter"), "2");
162
- await store.set("temporary", "value", 5);
163
- assert.equal(await store.get("temporary"), "value");
164
- now += 5000;
165
- assert.equal(await store.get("temporary"), null);
166
- await store.del("key");
167
- assert.equal(await store.get("key"), null);
168
- store.close();
169
- });
170
- test("sqlite store preserves values across database connections", { skip: !sqliteAvailable }, async () => {
171
- const directory = mkdtempSync(join(tmpdir(), "proactive-gate-"));
172
- const path = join(directory, "store.sqlite");
173
- try {
174
- const first = new SqliteStore(path);
175
- await first.set("key", "value");
176
- assert.equal(await first.incr("counter"), 1);
177
- first.close();
178
- const second = new SqliteStore(path);
179
- assert.equal(await second.get("key"), "value");
180
- assert.equal(await second.get("counter"), "1");
181
- second.close();
182
- }
183
- finally {
184
- rmSync(directory, { recursive: true, force: true });
185
- }
186
- });
187
- test("adaptive timing never rejects; it defers and can narrow surfaces", async () => {
188
- const later = new Date("2026-09-04T15:00:00Z");
189
- const gate = createGate({
190
- checks: [
191
- checks.adaptiveTiming({ nextGoodMoment: () => later, surfacesFor: () => ["feed"] }),
192
- { id: "rogue", nonRejecting: true, run: () => ({ kind: "reject", reason: "should be ignored" }) },
193
- ],
194
- });
195
- const d = await gate.evaluate({ user: user(), candidate: candidate(), now: noon });
196
- assert.equal(d.allowed, true);
197
- assert.equal(d.deliverAt?.toISOString(), later.toISOString());
198
- assert.deepEqual(d.surfaces, ["feed"]);
199
- assert.equal(d.trace[1]?.outcome, "skip");
200
- });
201
- test("store failure fails open by default and closed on request, and the trace says which", async () => {
202
- const broken = {
203
- get: async () => { throw new Error("redis down"); },
204
- set: async () => { throw new Error("redis down"); },
205
- incr: async () => { throw new Error("redis down"); },
206
- del: async () => { throw new Error("redis down"); },
207
- };
208
- const open = createGate({ store: broken, checks: [checks.dailyBudget({ limit: 1 })] });
209
- const d1 = await open.evaluate({ user: user(), candidate: candidate(), now: noon });
210
- assert.equal(d1.allowed, true);
211
- assert.match(d1.trace[0]?.reason ?? "", /failing open/);
212
- assert.equal(await open.commit(d1, { user: user(), candidate: candidate(), now: noon }), true);
213
- const closed = createGate({ store: broken, onStoreError: "closed", checks: [checks.dailyBudget({ limit: 1 })] });
214
- const d2 = await closed.evaluate({ user: user(), candidate: candidate(), now: noon });
215
- assert.equal(d2.allowed, false);
216
- assert.equal(d2.rejectedBy, "dailyBudget");
217
- });
218
- test("user surfaces filter candidate surfaces; onDecision sees every decision", async () => {
219
- const seen = [];
220
- const gate = createGate({ checks: defaultChecks(), onDecision: (d) => seen.push(`${d.candidateId}:${d.allowed}`) });
221
- const d = await gate.evaluate({ user: user({ surfaces: ["feed"] }), candidate: candidate({ surfaces: ["push", "feed", "voice"] }), now: noon });
222
- assert.deepEqual(d.surfaces, ["feed"]);
223
- await gate.evaluate({ user: user({ consent: false }), candidate: candidate({ id: "c2" }), now: noon });
224
- assert.deepEqual(seen, ["c1:true", "c2:false"]);
225
- });
226
- test("replay summarises a day of candidates and consumes the budget in order", async () => {
227
- const gate = createGate({ checks: defaultChecks({ dailyLimit: 2 }) });
228
- const lines = [
229
- JSON.stringify({ user: user(), candidate: candidate({ id: "a" }), now: noon }),
230
- JSON.stringify({ user: user(), candidate: candidate({ id: "b" }), now: noon }),
231
- JSON.stringify({ user: user(), candidate: candidate({ id: "c" }), now: noon }),
232
- JSON.stringify({ user: user(), candidate: candidate({ id: "d" }), now: night }),
233
- JSON.stringify({ user: user({ consent: false }), candidate: candidate({ id: "e" }), now: noon }),
234
- ];
235
- const decisions = await replay(lines, gate, true);
236
- assert.deepEqual(decisions.map((d) => d.allowed), [true, true, false, false, false]);
237
- const text = summarize(decisions);
238
- assert.match(text, /5 candidates {2}· {2}2 allowed \(40\.0%\)/);
239
- assert.match(text, /dailyBudget/);
240
- assert.match(text, /quietHours/);
241
- assert.match(text, /consent/);
242
- });
243
- test("a custom check is an ordinary object: it runs in order, reads the context, and shows in the trace", async () => {
244
- const weekendsOnlyHigh = {
245
- id: "weekendFloor",
246
- run: ({ now, priority }) => {
247
- const day = now.getUTCDay();
248
- if ((day === 0 || day === 6) && priority !== "high" && priority !== "critical")
249
- return { kind: "reject", reason: "weekend: only high priority" };
250
- return { kind: "pass" };
251
- },
252
- };
253
- const gate = createGate({ checks: [checks.consent(), weekendsOnlyHigh, checks.dailyBudget({ limit: 5 })] });
254
- const saturday = new Date("2026-09-05T10:00:00Z");
255
- const d = await gate.evaluate({ user: user(), candidate: candidate(), now: saturday });
256
- assert.equal(d.rejectedBy, "weekendFloor");
257
- assert.deepEqual(d.trace.map((t) => `${t.id}:${t.outcome}`), ["consent:pass", "weekendFloor:reject"]);
258
- const monday = await gate.evaluate({ user: user(), candidate: candidate(), now: new Date("2026-09-07T10:00:00Z") });
259
- assert.equal(monday.allowed, true);
260
- assert.equal(monday.trace.length, 3);
261
- });