proactive-gate 0.1.2 → 0.2.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.
@@ -29,6 +29,14 @@ export interface UserState {
29
29
  createdAt?: Date | string;
30
30
  /** Surfaces the user allows, in preference order. Defaults to the candidate's surfaces. */
31
31
  surfaces?: Surface[];
32
+ /** Named consents a preset can require, e.g. { ad: true, night: false }. */
33
+ consents?: Record<string, boolean>;
34
+ /** Last message the user sent to the assistant; drives inbound-window presets. */
35
+ lastInboundAt?: Date | string | null;
36
+ /** True when the user is a minor under the applicable rules. */
37
+ minor?: boolean;
38
+ /** True when a soft opt-in for existing customers applies. */
39
+ existingCustomer?: boolean;
32
40
  }
33
41
  /** The thing the agent wants to say. */
34
42
  export interface Candidate {
@@ -38,6 +46,14 @@ export interface Candidate {
38
46
  priority?: Priority;
39
47
  /** Surfaces this candidate can be delivered on, in preference order. */
40
48
  surfaces?: Surface[];
49
+ /** Channel or chat the message goes to; rate limits keyed by channel read it. */
50
+ channel?: string;
51
+ /** The caller's own signal that the user is busy right now; boundedDeferral reads it. */
52
+ busy?: boolean;
53
+ /** Caller-estimated probability the user accepts this message; utilityFloor reads it. */
54
+ pAccept?: number;
55
+ /** Caller-estimated probability the user needs it; utilityFloor reads it, default 1. */
56
+ pNeed?: number;
41
57
  /** Free-form payload; the gate never reads it. */
42
58
  payload?: unknown;
43
59
  }
@@ -50,6 +66,11 @@ export interface EvaluateInput {
50
66
  /** What a single check may say. */
51
67
  export type CheckOutcome = {
52
68
  kind: "pass";
69
+ reason?: string;
70
+ nearLimit?: {
71
+ used: number;
72
+ limit: number;
73
+ };
53
74
  } | {
54
75
  kind: "reject";
55
76
  reason: string;
@@ -61,6 +82,10 @@ export type CheckOutcome = {
61
82
  } | {
62
83
  kind: "skip";
63
84
  reason: string;
85
+ } | {
86
+ kind: "defer";
87
+ reason: string;
88
+ retryAt: Date;
64
89
  };
65
90
  export interface CheckContext {
66
91
  user: UserState;
@@ -75,26 +100,50 @@ export interface Check {
75
100
  id: string;
76
101
  /** True when the check can never reject; it only adjusts timing or surfaces. */
77
102
  nonRejecting?: boolean;
103
+ /** True to record what the check would have done without letting it stop evaluation. */
104
+ shadow?: boolean;
78
105
  run(ctx: CheckContext): Promise<CheckOutcome> | CheckOutcome;
106
+ /**
107
+ * Budget-like checks consume one unit at commit time. Return false when the
108
+ * unit was not available (a concurrent delivery took it). The gate calls
109
+ * consume() in check order, once per decision.
110
+ */
111
+ consume?(ctx: CheckContext): Promise<boolean>;
79
112
  }
80
113
  export interface TraceEntry {
81
114
  id: string;
82
115
  outcome: CheckOutcome["kind"];
83
116
  reason?: string;
84
117
  ms: number;
118
+ /** Present when the check ran in shadow mode and would have stopped evaluation. */
119
+ shadow?: boolean;
85
120
  }
86
121
  export interface Decision {
122
+ /** Unique per evaluation: userId, candidateId, the instant and a sequence number. commit() is idempotent on it. */
123
+ id: string;
87
124
  allowed: boolean;
88
125
  userId: string;
89
126
  candidateId: string;
90
- /** Surfaces to route to when allowed. Empty when rejected. */
127
+ /** Surfaces to route to when allowed. Empty when rejected or deferred. */
91
128
  surfaces: Surface[];
92
129
  /** Set when a non-rejecting check asked for a later delivery. */
93
130
  deliverAt?: Date;
94
131
  /** The check that rejected, when rejected. */
95
132
  rejectedBy?: string;
96
- /** Human-readable reason, when rejected. */
133
+ /** The check that deferred, when deferred. */
134
+ deferredBy?: string;
135
+ /** When to evaluate again, when deferred. */
136
+ retryAt?: Date;
137
+ /** Human-readable reason, when rejected or deferred. */
97
138
  reason?: string;
139
+ /** Checks in shadow mode that would have rejected or deferred. */
140
+ shadowed: string[];
141
+ /** Budget checks that passed close to their limit. */
142
+ nearLimit: Array<{
143
+ check: string;
144
+ used: number;
145
+ limit: number;
146
+ }>;
98
147
  /** Every check that ran, in order, with what it said. */
99
148
  trace: TraceEntry[];
100
149
  evaluatedAt: Date;
@@ -113,6 +162,13 @@ export interface Store {
113
162
  incr(key: string, ttlSeconds?: number): Promise<number>;
114
163
  del(key: string): Promise<void>;
115
164
  }
165
+ /** Observation points. Hooks never change a decision; a throwing hook is reported to `error` and ignored. */
166
+ export interface GateHooks {
167
+ before?(ctx: CheckContext, check: Check): void | Promise<void>;
168
+ after?(ctx: CheckContext, check: Check, outcome: CheckOutcome, ms: number): void | Promise<void>;
169
+ error?(ctx: CheckContext, check: Check, error: unknown): void | Promise<void>;
170
+ finally?(decision: Decision): void | Promise<void>;
171
+ }
116
172
  export interface GateOptions {
117
173
  checks: Check[];
118
174
  store?: Store;
@@ -126,4 +182,20 @@ export interface GateOptions {
126
182
  onDecision?: (decision: Decision) => void;
127
183
  /** Key prefix for everything the gate writes to the store. */
128
184
  keyPrefix?: string;
185
+ /** Observation hooks, e.g. one OpenTelemetry span per check. */
186
+ hooks?: GateHooks;
129
187
  }
188
+ /** A policy document: the same checks as data. See spec/schema/policy.schema.json. */
189
+ export interface Policy {
190
+ specVersion: string;
191
+ onStoreError?: "open" | "closed";
192
+ keyPrefix?: string;
193
+ checks: PolicyEntry[];
194
+ }
195
+ export type PolicyEntry = ({
196
+ id: string;
197
+ shadow?: boolean;
198
+ } & Record<string, unknown>) | ({
199
+ preset: string;
200
+ shadow?: boolean;
201
+ } & Record<string, unknown>);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.1.2",
4
- "description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks: kill switch, consent, quiet hours, trust ramp, dismissal cooldown, daily budget.",
3
+ "version": "0.2.0",
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",
7
7
  "types": "./dist/src/index.d.ts",
@@ -9,23 +9,51 @@
9
9
  ".": {
10
10
  "types": "./dist/src/index.d.ts",
11
11
  "import": "./dist/src/index.js"
12
- }
12
+ },
13
+ "./presets": {
14
+ "types": "./dist/src/presets.d.ts",
15
+ "import": "./dist/src/presets.js"
16
+ },
17
+ "./ai-sdk": {
18
+ "types": "./dist/src/adapters/ai-sdk.d.ts",
19
+ "import": "./dist/src/adapters/ai-sdk.js"
20
+ },
21
+ "./mastra": {
22
+ "types": "./dist/src/adapters/mastra.d.ts",
23
+ "import": "./dist/src/adapters/mastra.js"
24
+ },
25
+ "./langchain": {
26
+ "types": "./dist/src/adapters/langchain.d.ts",
27
+ "import": "./dist/src/adapters/langchain.js"
28
+ },
29
+ "./openai-agents": {
30
+ "types": "./dist/src/adapters/openai-agents.d.ts",
31
+ "import": "./dist/src/adapters/openai-agents.js"
32
+ },
33
+ "./package.json": "./package.json"
13
34
  },
14
35
  "bin": {
15
36
  "proactive-gate": "dist/src/cli.js"
16
37
  },
17
38
  "files": [
18
- "dist",
39
+ "dist/src",
19
40
  "README.md",
20
41
  "LICENSE"
21
42
  ],
43
+ "sideEffects": false,
22
44
  "scripts": {
23
45
  "build": "tsc -p tsconfig.json",
24
- "test": "npm run build && node --test dist/test/gate.test.js",
46
+ "test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js test/release.test.mjs test/examples.test.mjs test/naive.test.mjs",
25
47
  "lint": "tsc -p tsconfig.json --noEmit",
48
+ "spec-lint": "node test/spec-lint.mjs",
49
+ "conformance": "npm run build && node dist/src/cli.js replay --fixtures spec/fixtures",
26
50
  "prepublishOnly": "npm test",
27
- "examples": "npm run build && node dist/src/cli.js replay examples/day.jsonl --policy examples/policy.js --commit",
28
- "bench": "npm run build && node bench/evaluate.mjs"
51
+ "examples": "npm run build && node dist/src/cli.js replay examples/day.jsonl --policy examples/policy.js --commit && node dist/src/cli.js replay examples/day.jsonl --policy examples/policy.json --commit && node examples/mastra/run.mjs && node examples/ai-sdk/run.mjs",
52
+ "bench": "npm run build && node bench/evaluate.mjs",
53
+ "release": "node scripts/release.mjs",
54
+ "release-gate": "npm run build && node scripts/release-gate.mjs",
55
+ "trace-svg": "npm run build && node scripts/trace-svg.mjs",
56
+ "bench:compare": "npm run build && node bench/compare.mjs"
29
57
  },
30
58
  "engines": {
31
59
  "node": ">=20"
@@ -39,7 +67,20 @@
39
67
  "quiet-hours",
40
68
  "consent",
41
69
  "budget",
42
- "llm"
70
+ "llm",
71
+ "policy",
72
+ "presets",
73
+ "langchain",
74
+ "mastra",
75
+ "ai-sdk",
76
+ "openai-agents",
77
+ "claude-code",
78
+ "guardrails",
79
+ "agent-guardrails",
80
+ "notification-budget",
81
+ "proactive-ai",
82
+ "python",
83
+ "conformance"
43
84
  ],
44
85
  "author": "Efe Genc",
45
86
  "license": "MIT",
@@ -47,7 +88,14 @@
47
88
  "type": "git",
48
89
  "url": "git+https://github.com/Bubblegunn/proactive-gate.git"
49
90
  },
50
- "homepage": "https://github.com/Bubblegunn/proactive-gate#readme",
91
+ "homepage": "https://bubblegunn.github.io/proactive-gate/",
92
+ "bugs": {
93
+ "url": "https://github.com/Bubblegunn/proactive-gate/issues"
94
+ },
95
+ "publishConfig": {
96
+ "access": "public",
97
+ "provenance": true
98
+ },
51
99
  "devDependencies": {
52
100
  "@arethetypeswrong/cli": "^0.18.5",
53
101
  "@types/node": "^26.4.1",
@@ -1 +0,0 @@
1
- export {};
@@ -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
- });