consolidation-scheduler.test.mjs
610 lines 22.3 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tests for netlify/functions/consolidation-scheduler.mjs (Stream 0 — Session 10).
3 *
4 * Covers:
5 * - isUserDue: enabled=false, never run, not yet due, exactly due, overdue
6 * - runScheduler: skips disabled users
7 * - runScheduler: skips users not yet due
8 * - runScheduler: triggers due users, updates consolidation_last_pass_at
9 * - runScheduler: per-user errors do not abort the rest of the run
10 * - runScheduler: MAX_USERS_PER_RUN cap is respected
11 * - runScheduler: shadow mode (BILLING_ENFORCE=false) logs intent, skips bridge call
12 * - runScheduler: missing SESSION_SECRET throws
13 * - runScheduler: missing BRIDGE_URL throws
14 * - normalizeBillingUser: consolidation_enabled defaults to false
15 * - defaultUserRecord: includes consolidation_enabled=false
16 */
17
18 import { describe, it } from 'node:test';
19 import assert from 'node:assert/strict';
20
21 import { isUserDue, signServiceJwt, runScheduler } from '../netlify/functions/consolidation-scheduler.mjs';
22 import { normalizeBillingUser, defaultUserRecord } from '../hub/gateway/billing-logic.mjs';
23 import jwt from 'jsonwebtoken';
24
25 // ── Fixtures ──────────────────────────────────────────────────────────────────
26
27 const SECRET = 'test-session-secret-32-chars-long!';
28 const BRIDGE = 'https://bridge.example.com';
29 const NOW_MS = new Date('2026-04-05T12:00:00.000Z').getTime();
30
31 function makeUser(overrides = {}) {
32 return {
33 user_id: 'user_' + Math.random().toString(36).slice(2, 8),
34 tier: 'plus',
35 consolidation_enabled: true,
36 consolidation_interval_minutes: 60,
37 consolidation_last_pass_at: null,
38 ...overrides,
39 };
40 }
41
42 /** Returns a mock billing DB with the given users. */
43 function makeDb(users) {
44 const map = {};
45 for (const u of users) map[u.user_id] = { ...u };
46 return { users: map, processed_events: [] };
47 }
48
49 /**
50 * Builds the standard runScheduler options with mocked loadDb / mutateDb / fetchFn.
51 * @param {object[]} users - Initial user list
52 * @param {object} fetchResponses - Map of userId → { ok, json?, text? } to control fetch
53 */
54 function makeOpts(users, fetchResponses = {}) {
55 let db = makeDb(users);
56
57 const loadDb = async () => JSON.parse(JSON.stringify(db));
58
59 const mutateDb = async (fn) => {
60 const copy = JSON.parse(JSON.stringify(db));
61 fn(copy);
62 db = copy;
63 };
64
65 const getDb = () => db;
66
67 const fetchFn = async (url) => {
68 // Derive userId from the URL or use a per-user override keyed by the first matching userId.
69 const matchedUser = users.find(u => Object.prototype.hasOwnProperty.call(fetchResponses, u.user_id));
70 const override = matchedUser ? fetchResponses[matchedUser.user_id] : null;
71
72 if (override?.throws) throw override.throws;
73
74 const ok = override ? override.ok !== false : true;
75 const json = override?.json ?? { topics: 2, total_events: 10, cost_usd: 0.003, pass_id: 'cpass_test' };
76 const text = override?.text ?? JSON.stringify(json);
77
78 return {
79 ok,
80 status: ok ? 200 : (override?.status ?? 500),
81 json: async () => json,
82 text: async () => text,
83 };
84 };
85
86 return { loadDb, mutateDb, getDb, fetchFn };
87 }
88
89 // ── isUserDue ─────────────────────────────────────────────────────────────────
90
91 describe('isUserDue', () => {
92 it('returns false when consolidation_enabled is false', () => {
93 const user = makeUser({ consolidation_enabled: false });
94 assert.equal(isUserDue(user, NOW_MS), false);
95 });
96
97 it('returns false when consolidation_interval_minutes is 0', () => {
98 const user = makeUser({ consolidation_interval_minutes: 0 });
99 assert.equal(isUserDue(user, NOW_MS), false);
100 });
101
102 it('returns false when consolidation_interval_minutes is negative', () => {
103 const user = makeUser({ consolidation_interval_minutes: -60 });
104 assert.equal(isUserDue(user, NOW_MS), false);
105 });
106
107 it('returns false when consolidation_interval_minutes is NaN/null', () => {
108 assert.equal(isUserDue(makeUser({ consolidation_interval_minutes: null }), NOW_MS), false);
109 assert.equal(isUserDue(makeUser({ consolidation_interval_minutes: NaN }), NOW_MS), false);
110 });
111
112 it('returns true when last_pass_at is null (never run)', () => {
113 const user = makeUser({ consolidation_last_pass_at: null });
114 assert.equal(isUserDue(user, NOW_MS), true);
115 });
116
117 it('returns true when last_pass_at is missing', () => {
118 const user = makeUser({ consolidation_last_pass_at: undefined });
119 assert.equal(isUserDue(user, NOW_MS), true);
120 });
121
122 it('returns true when interval has elapsed', () => {
123 const lastPass = NOW_MS - 61 * 60_000; // 61 minutes ago, interval = 60 min
124 const user = makeUser({
125 consolidation_interval_minutes: 60,
126 consolidation_last_pass_at: new Date(lastPass).toISOString(),
127 });
128 assert.equal(isUserDue(user, NOW_MS), true);
129 });
130
131 it('returns true at exactly the due moment (lastPass + interval === now)', () => {
132 const lastPass = NOW_MS - 60 * 60_000; // exactly 60 minutes ago
133 const user = makeUser({
134 consolidation_interval_minutes: 60,
135 consolidation_last_pass_at: new Date(lastPass).toISOString(),
136 });
137 assert.equal(isUserDue(user, NOW_MS), true);
138 });
139
140 it('returns false when interval has NOT elapsed', () => {
141 const lastPass = NOW_MS - 59 * 60_000; // 59 min ago, interval = 60 min
142 const user = makeUser({
143 consolidation_interval_minutes: 60,
144 consolidation_last_pass_at: new Date(lastPass).toISOString(),
145 });
146 assert.equal(isUserDue(user, NOW_MS), false);
147 });
148
149 it('returns false for null user', () => {
150 assert.equal(isUserDue(null, NOW_MS), false);
151 });
152 });
153
154 // ── signServiceJwt ────────────────────────────────────────────────────────────
155
156 describe('signServiceJwt', () => {
157 it('produces a verifiable JWT with sub = userId and role = service', () => {
158 const token = signServiceJwt('user_abc', SECRET);
159 const payload = jwt.verify(token, SECRET);
160 assert.equal(payload.sub, 'user_abc');
161 assert.equal(payload.role, 'service');
162 });
163
164 it('expires in approximately 5 minutes', () => {
165 const before = Math.floor(Date.now() / 1000);
166 const token = signServiceJwt('user_abc', SECRET);
167 const payload = jwt.decode(token);
168 const ttl = payload.exp - before;
169 assert.ok(ttl > 280 && ttl <= 310, `expected ~300s TTL, got ${ttl}s`);
170 });
171 });
172
173 // ── runScheduler: guard conditions ────────────────────────────────────────────
174
175 describe('runScheduler — guard conditions', () => {
176 it('throws when SESSION_SECRET is missing', async () => {
177 await assert.rejects(
178 () =>
179 runScheduler({
180 sessionSecret: '',
181 bridgeUrl: BRIDGE,
182 billingEnforce: true,
183 nowMs: NOW_MS,
184 loadDb: async () => makeDb([]),
185 mutateDb: async () => {},
186 fetchFn: async () => {},
187 }),
188 /SESSION_SECRET/,
189 );
190 });
191
192 it('throws when BRIDGE_URL is missing', async () => {
193 await assert.rejects(
194 () =>
195 runScheduler({
196 sessionSecret: SECRET,
197 bridgeUrl: '',
198 billingEnforce: true,
199 nowMs: NOW_MS,
200 loadDb: async () => makeDb([]),
201 mutateDb: async () => {},
202 fetchFn: async () => {},
203 }),
204 /BRIDGE_URL/,
205 );
206 });
207 });
208
209 // ── runScheduler: users with consolidation_enabled=false are skipped ──────────
210
211 describe('runScheduler — skips disabled users', () => {
212 it('does not call bridge for users with consolidation_enabled=false', async () => {
213 const users = [
214 makeUser({ consolidation_enabled: false }),
215 makeUser({ consolidation_enabled: false }),
216 ];
217 let fetchCalled = 0;
218 const { loadDb, mutateDb } = makeOpts(users);
219
220 const result = await runScheduler({
221 sessionSecret: SECRET,
222 bridgeUrl: BRIDGE,
223 billingEnforce: true,
224 nowMs: NOW_MS,
225 loadDb,
226 mutateDb,
227 fetchFn: async () => { fetchCalled++; return { ok: true, json: async () => ({}) }; },
228 });
229
230 assert.equal(fetchCalled, 0);
231 assert.equal(result.pass_count, 0);
232 assert.equal(result.skipped_not_enabled, 2);
233 assert.equal(result.skipped_not_due, 0);
234 });
235 });
236
237 // ── runScheduler: users not yet due are skipped ───────────────────────────────
238
239 describe('runScheduler — skips users not yet due', () => {
240 it('does not call bridge for users whose interval has not elapsed', async () => {
241 const recentPassAt = new Date(NOW_MS - 30 * 60_000).toISOString(); // 30 min ago, interval 60 min
242 const users = [
243 makeUser({ consolidation_interval_minutes: 60, consolidation_last_pass_at: recentPassAt }),
244 makeUser({ consolidation_interval_minutes: 120, consolidation_last_pass_at: recentPassAt }),
245 ];
246 let fetchCalled = 0;
247 const { loadDb, mutateDb } = makeOpts(users);
248
249 const result = await runScheduler({
250 sessionSecret: SECRET,
251 bridgeUrl: BRIDGE,
252 billingEnforce: true,
253 nowMs: NOW_MS,
254 loadDb,
255 mutateDb,
256 fetchFn: async () => { fetchCalled++; return { ok: true, json: async () => ({}) }; },
257 });
258
259 assert.equal(fetchCalled, 0);
260 assert.equal(result.pass_count, 0);
261 assert.equal(result.skipped_not_due, 2);
262 });
263 });
264
265 // ── runScheduler: due users are triggered and last_pass_at updated ────────────
266
267 describe('runScheduler — triggers due users and updates last_pass_at', () => {
268 it('calls bridge for each due user and stamps consolidation_last_pass_at', async () => {
269 const overdueAt = new Date(NOW_MS - 90 * 60_000).toISOString(); // 90 min ago, interval 60 min
270 const userA = makeUser({ consolidation_interval_minutes: 60, consolidation_last_pass_at: overdueAt });
271 const userB = makeUser({ consolidation_interval_minutes: 60, consolidation_last_pass_at: null });
272 const users = [userA, userB];
273
274 const fetchedUrls = [];
275 const fetchedTokenSubs = [];
276
277 const { loadDb, mutateDb, getDb } = makeOpts(users);
278
279 const fetchFn = async (url, init) => {
280 fetchedUrls.push(url);
281 const authHeader = init?.headers?.Authorization ?? '';
282 const token = authHeader.replace('Bearer ', '');
283 const payload = jwt.verify(token, SECRET);
284 fetchedTokenSubs.push(payload.sub);
285
286 return {
287 ok: true,
288 json: async () => ({ topics: 3, total_events: 15, cost_usd: 0.004, pass_id: 'cpass_xyz' }),
289 text: async () => '',
290 };
291 };
292
293 const result = await runScheduler({
294 sessionSecret: SECRET,
295 bridgeUrl: BRIDGE,
296 billingEnforce: true,
297 nowMs: NOW_MS,
298 loadDb,
299 mutateDb,
300 fetchFn,
301 });
302
303 assert.equal(result.pass_count, 2);
304 assert.equal(result.errors.length, 0);
305 assert.equal(fetchedUrls.length, 2);
306 assert.ok(fetchedUrls.every(u => u === `${BRIDGE}/api/v1/memory/consolidate`));
307
308 // Each JWT must carry the correct user ID.
309 assert.ok(fetchedTokenSubs.includes(userA.user_id));
310 assert.ok(fetchedTokenSubs.includes(userB.user_id));
311
312 // consolidation_last_pass_at must be updated in the DB for both users.
313 const finalDb = getDb();
314 const expectedTs = new Date(NOW_MS).toISOString();
315 assert.equal(finalDb.users[userA.user_id].consolidation_last_pass_at, expectedTs);
316 assert.equal(finalDb.users[userB.user_id].consolidation_last_pass_at, expectedTs);
317 });
318
319 it('sets Content-Type: application/json on the bridge request', async () => {
320 const user = makeUser({ consolidation_last_pass_at: null });
321 let capturedHeaders = null;
322 const { loadDb, mutateDb } = makeOpts([user]);
323
324 await runScheduler({
325 sessionSecret: SECRET,
326 bridgeUrl: BRIDGE,
327 billingEnforce: true,
328 nowMs: NOW_MS,
329 loadDb,
330 mutateDb,
331 fetchFn: async (_url, init) => {
332 capturedHeaders = init?.headers ?? {};
333 return { ok: true, json: async () => ({}), text: async () => '' };
334 },
335 });
336
337 assert.equal(capturedHeaders['Content-Type'], 'application/json');
338 });
339
340 it('POST body includes lookback and caps from billing user record', async () => {
341 const user = makeUser({
342 consolidation_last_pass_at: null,
343 consolidation_lookback_hours: 48,
344 consolidation_max_events_per_pass: 99,
345 consolidation_max_topics_per_pass: 4,
346 consolidation_llm_max_tokens: 2048,
347 });
348 let parsed = null;
349 const { loadDb, mutateDb } = makeOpts([user]);
350
351 await runScheduler({
352 sessionSecret: SECRET,
353 bridgeUrl: BRIDGE,
354 billingEnforce: true,
355 nowMs: NOW_MS,
356 loadDb,
357 mutateDb,
358 fetchFn: async (_url, init) => {
359 parsed = JSON.parse(init.body);
360 return { ok: true, json: async () => ({ topics: [], total_events: 0 }), text: async () => '' };
361 },
362 });
363
364 assert.equal(parsed.lookback_hours, 48);
365 assert.equal(parsed.max_events_per_pass, 99);
366 assert.equal(parsed.max_topics_per_pass, 4);
367 assert.equal(parsed.llm.max_tokens, 2048);
368 assert.equal(parsed.passes.consolidate, true);
369 assert.equal(parsed.passes.verify, true);
370 });
371 });
372
373 // ── runScheduler: per-user errors do not abort the run ────────────────────────
374
375 describe('runScheduler — per-user errors do not abort the run', () => {
376 it('logs error for the failing user and continues processing remaining users', async () => {
377 const failUser = makeUser({ consolidation_last_pass_at: null });
378 const okUser = makeUser({ consolidation_last_pass_at: null });
379 const { loadDb, mutateDb, getDb } = makeOpts([failUser, okUser]);
380
381 let callCount = 0;
382 const fetchFn = async (_url, init) => {
383 callCount++;
384 // Identify which user by decoding the JWT sub claim.
385 const auth = init?.headers?.Authorization ?? '';
386 const token = auth.replace('Bearer ', '');
387 const payload = jwt.decode(token);
388
389 if (payload?.sub === failUser.user_id) {
390 return { ok: false, status: 500, json: async () => ({}), text: async () => 'internal error' };
391 }
392 return { ok: true, json: async () => ({ topics: 1, total_events: 5, cost_usd: 0.001, pass_id: 'cpass_ok' }), text: async () => '' };
393 };
394
395 const result = await runScheduler({
396 sessionSecret: SECRET,
397 bridgeUrl: BRIDGE,
398 billingEnforce: true,
399 nowMs: NOW_MS,
400 loadDb,
401 mutateDb,
402 fetchFn,
403 });
404
405 // Both users were attempted.
406 assert.equal(callCount, 2);
407 // One succeeded, one failed.
408 assert.equal(result.pass_count, 1);
409 assert.equal(result.errors.length, 1);
410 assert.equal(result.errors[0].user_id, failUser.user_id);
411 assert.ok(result.errors[0].error.includes('500'));
412
413 // The successful user's last_pass_at must be updated.
414 const finalDb = getDb();
415 assert.equal(finalDb.users[okUser.user_id].consolidation_last_pass_at, new Date(NOW_MS).toISOString());
416 // The failed user's last_pass_at must NOT be updated.
417 assert.equal(finalDb.users[failUser.user_id].consolidation_last_pass_at, null);
418 });
419
420 it('handles fetch throwing (network error) without aborting the run', async () => {
421 const netErrUser = makeUser({ consolidation_last_pass_at: null });
422 const okUser = makeUser({ consolidation_last_pass_at: null });
423 const { loadDb, mutateDb } = makeOpts([netErrUser, okUser]);
424
425 const fetchFn = async (_url, init) => {
426 const auth = init?.headers?.Authorization ?? '';
427 const payload = jwt.decode(auth.replace('Bearer ', ''));
428 if (payload?.sub === netErrUser.user_id) throw new Error('ECONNREFUSED');
429 return { ok: true, json: async () => ({ topics: 1, total_events: 3, cost_usd: 0.001, pass_id: 'p1' }), text: async () => '' };
430 };
431
432 const result = await runScheduler({
433 sessionSecret: SECRET,
434 bridgeUrl: BRIDGE,
435 billingEnforce: true,
436 nowMs: NOW_MS,
437 loadDb,
438 mutateDb,
439 fetchFn,
440 });
441
442 assert.equal(result.pass_count, 1);
443 assert.equal(result.errors.length, 1);
444 assert.ok(result.errors[0].error.includes('ECONNREFUSED'));
445 });
446 });
447
448 // ── runScheduler: MAX_USERS_PER_RUN cap ───────────────────────────────────────
449
450 describe('runScheduler — MAX_USERS_PER_RUN cap is respected', () => {
451 it('only processes up to maxUsersPerRun users even when more are due', async () => {
452 const users = Array.from({ length: 10 }, () => makeUser({ consolidation_last_pass_at: null }));
453 let fetchCalled = 0;
454 const { loadDb, mutateDb } = makeOpts(users);
455
456 const result = await runScheduler({
457 sessionSecret: SECRET,
458 bridgeUrl: BRIDGE,
459 billingEnforce: true,
460 maxUsersPerRun: 3,
461 nowMs: NOW_MS,
462 loadDb,
463 mutateDb,
464 fetchFn: async () => {
465 fetchCalled++;
466 return { ok: true, json: async () => ({ topics: 1, total_events: 5, cost_usd: 0.001, pass_id: 'px' }), text: async () => '' };
467 },
468 });
469
470 assert.equal(fetchCalled, 3);
471 assert.equal(result.pass_count, 3);
472 assert.equal(result.capped, 7); // 10 due − 3 processed = 7 capped
473 });
474
475 it('processes all users when count is below the cap', async () => {
476 const users = Array.from({ length: 2 }, () => makeUser({ consolidation_last_pass_at: null }));
477 let fetchCalled = 0;
478 const { loadDb, mutateDb } = makeOpts(users);
479
480 const result = await runScheduler({
481 sessionSecret: SECRET,
482 bridgeUrl: BRIDGE,
483 billingEnforce: true,
484 maxUsersPerRun: 20,
485 nowMs: NOW_MS,
486 loadDb,
487 mutateDb,
488 fetchFn: async () => {
489 fetchCalled++;
490 return { ok: true, json: async () => ({}), text: async () => '' };
491 },
492 });
493
494 assert.equal(fetchCalled, 2);
495 assert.equal(result.capped, 0);
496 });
497 });
498
499 // ── runScheduler: shadow mode (BILLING_ENFORCE !== 'true') ────────────────────
500
501 describe('runScheduler — shadow mode', () => {
502 it('does not call bridge when billingEnforce=false, but counts the pass', async () => {
503 const user = makeUser({ consolidation_last_pass_at: null });
504 let fetchCalled = 0;
505 const { loadDb, mutateDb, getDb } = makeOpts([user]);
506
507 const result = await runScheduler({
508 sessionSecret: SECRET,
509 bridgeUrl: BRIDGE,
510 billingEnforce: false,
511 nowMs: NOW_MS,
512 loadDb,
513 mutateDb,
514 fetchFn: async () => { fetchCalled++; return { ok: true, json: async () => ({}) }; },
515 });
516
517 assert.equal(fetchCalled, 0, 'bridge must not be called in shadow mode');
518 assert.equal(result.pass_count, 1, 'shadow pass is still counted');
519 assert.equal(result.shadow_mode, true);
520
521 // last_pass_at must NOT be updated in shadow mode.
522 const finalDb = getDb();
523 assert.equal(finalDb.users[user.user_id].consolidation_last_pass_at, null);
524 });
525
526 it('reports shadow_mode=false when billingEnforce=true', async () => {
527 const { loadDb, mutateDb } = makeOpts([]);
528 const result = await runScheduler({
529 sessionSecret: SECRET,
530 bridgeUrl: BRIDGE,
531 billingEnforce: true,
532 nowMs: NOW_MS,
533 loadDb,
534 mutateDb,
535 fetchFn: async () => ({ ok: true, json: async () => ({}) }),
536 });
537 assert.equal(result.shadow_mode, false);
538 });
539 });
540
541 // ── runScheduler: mixed enabled/disabled/due/not-due ─────────────────────────
542
543 describe('runScheduler — mixed user states', () => {
544 it('correctly partitions skipped_not_enabled, skipped_not_due, and triggered users', async () => {
545 const recentAt = new Date(NOW_MS - 30 * 60_000).toISOString();
546 const overdueAt = new Date(NOW_MS - 90 * 60_000).toISOString();
547
548 const disabled = makeUser({ consolidation_enabled: false });
549 const notDue = makeUser({ consolidation_interval_minutes: 60, consolidation_last_pass_at: recentAt });
550 const due1 = makeUser({ consolidation_interval_minutes: 60, consolidation_last_pass_at: overdueAt });
551 const due2 = makeUser({ consolidation_interval_minutes: 60, consolidation_last_pass_at: null });
552
553 const { loadDb, mutateDb } = makeOpts([disabled, notDue, due1, due2]);
554
555 const result = await runScheduler({
556 sessionSecret: SECRET,
557 bridgeUrl: BRIDGE,
558 billingEnforce: true,
559 nowMs: NOW_MS,
560 loadDb,
561 mutateDb,
562 fetchFn: async () => ({ ok: true, json: async () => ({ topics: 1, total_events: 2, cost_usd: 0.001, pass_id: 'p' }), text: async () => '' }),
563 });
564
565 assert.equal(result.skipped_not_enabled, 1); // disabled
566 assert.equal(result.skipped_not_due, 1); // notDue
567 assert.equal(result.pass_count, 2); // due1 + due2
568 assert.equal(result.errors.length, 0);
569 });
570 });
571
572 // ── billing-logic: consolidation_enabled field ────────────────────────────────
573
574 describe('normalizeBillingUser — consolidation_enabled', () => {
575 it('defaults consolidation_enabled to false when field is missing', () => {
576 const u = { user_id: 'u1', monthly_consolidation_jobs_used: 0 };
577 normalizeBillingUser(u);
578 assert.equal(u.consolidation_enabled, false);
579 });
580
581 it('preserves consolidation_enabled=true if already set', () => {
582 const u = { user_id: 'u1', consolidation_enabled: true, monthly_consolidation_jobs_used: 0 };
583 normalizeBillingUser(u);
584 assert.equal(u.consolidation_enabled, true);
585 });
586
587 it('preserves consolidation_enabled=false if explicitly set', () => {
588 const u = { user_id: 'u1', consolidation_enabled: false, monthly_consolidation_jobs_used: 0 };
589 normalizeBillingUser(u);
590 assert.equal(u.consolidation_enabled, false);
591 });
592 });
593
594 describe('defaultUserRecord — consolidation_enabled', () => {
595 it('includes consolidation_enabled=false in the default record', () => {
596 const u = defaultUserRecord('u_test');
597 assert.equal(Object.prototype.hasOwnProperty.call(u, 'consolidation_enabled'), true);
598 assert.equal(u.consolidation_enabled, false);
599 });
600
601 it('includes consolidation_last_pass_at=null', () => {
602 const u = defaultUserRecord('u_test');
603 assert.equal(u.consolidation_last_pass_at, null);
604 });
605
606 it('includes consolidation_interval_minutes=null', () => {
607 const u = defaultUserRecord('u_test');
608 assert.equal(u.consolidation_interval_minutes, null);
609 });
610 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago