slotlock 0.1.1 → 0.3.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/CONTRIBUTING.md +55 -19
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +403 -103
  7. package/SECURITY.md +70 -5
  8. package/SPEC.md +161 -21
  9. package/agent-server.d.ts +76 -10
  10. package/agent-server.js +381 -146
  11. package/agent-store-backend.d.ts +22 -0
  12. package/agent-store-backend.js +598 -24
  13. package/api-keys.d.ts +22 -7
  14. package/api-keys.js +38 -8
  15. package/application-api.d.ts +67 -0
  16. package/application-api.js +552 -0
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +10 -1
  20. package/dashboard.js +23 -5
  21. package/ddl.d.ts +47 -4
  22. package/ddl.js +679 -25
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/hooks.d.ts +7 -0
  27. package/hooks.js +13 -0
  28. package/ical-worker-thread.js +4 -2
  29. package/ical-worker.d.ts +8 -5
  30. package/ical-worker.js +9 -2
  31. package/index.d.ts +12 -8
  32. package/index.js +7 -5
  33. package/maintenance.d.ts +67 -0
  34. package/maintenance.js +213 -0
  35. package/node-server.d.ts +9 -1
  36. package/node-server.js +127 -6
  37. package/openapi.json +6024 -0
  38. package/package.json +3 -2
  39. package/rate-limit.d.ts +71 -0
  40. package/rate-limit.js +718 -0
  41. package/rest-api.d.ts +33 -18
  42. package/rest-api.js +141 -63
  43. package/rules.d.ts +19 -0
  44. package/rules.js +38 -7
  45. package/self-host.d.ts +26 -9
  46. package/self-host.js +427 -47
  47. package/slotlock-manifest.schema.json +6 -1
  48. package/slotlock.manifest.json +7 -2
  49. package/store.d.ts +359 -27
  50. package/store.js +2077 -296
  51. package/sync.d.ts +43 -2
  52. package/sync.js +1950 -277
  53. package/timezone.d.ts +77 -3
  54. package/timezone.js +265 -21
  55. package/types.d.ts +217 -10
  56. package/weekly-hours.js +2 -2
package/README.md CHANGED
@@ -5,6 +5,42 @@ reservable, answers "when is this free?" from weekly availability rules, and let
5
5
  double bookings, however many writers race. Agents call it over MCP or A2A; your code calls it as a
6
6
  TypeScript library.
7
7
 
8
+ ## Known issues in 0.2.0
9
+
10
+ Slotlock 0.2.0 can occupy less time than an event was written with, so another booking can take the
11
+ rest: around daylight-saving changes, in any time zone that has them, and at the end of some series.
12
+ The library and `slotlock serve` are both affected (the last case below is in the library's
13
+ iCalendar readers only); the 0.1 releases are older still and unsupported. 0.3.0 fixes all six
14
+ cases; while you run 0.2.0, do not rely on it alone to refuse bookings in them.
15
+
16
+ - A recurring event whose first occurrence spans a daylight-saving change gets that occurrence an
17
+ hour short: in spring, an hour of the written time is free to book.
18
+ - Recurring events written through the store (the agent tools and the application API) lose the
19
+ repeated hour of an autumn change: times in it move an hour early, and a series whose first
20
+ occurrence spans it can store no occurrences, so all of its time is free.
21
+ - A stored recurring series cannot cancel or move an occurrence that falls in the skipped hour of a
22
+ spring change; the time it was moved to stays free.
23
+ - An occurrence that starts in the skipped hour and lasts longer than the skipped hour is cut short:
24
+ a 75-minute occurrence occupies 15 minutes.
25
+ - A timed recurring series with a date-only `UNTIL` ends at midnight UTC, so it can lose its last
26
+ day.
27
+ - An event read from iCalendar (`parseICalendarChanges`, `parseTrustedICalendarEvents` or the
28
+ worker pool) whose `DURATION` counts hours loses an hour across a spring change, whether it is a
29
+ single event, a series, an `RDATE` period or a moved occurrence: `DURATION:PT48H` occupies 47
30
+ hours.
31
+
32
+ 0.1.0, 0.1.1 and 0.2.0 also pin zod 4.6.5 exactly, which has a high advisory
33
+ ([GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5): validating an oversized
34
+ array can exhaust memory), which `npm audit` reports. While you run one of them, add this to your
35
+ application's `package.json` and run `npm install`:
36
+
37
+ ```json
38
+ "overrides": {"zod": "4.6.7"}
39
+ ```
40
+
41
+ The full notice, with the versions affected, is in
42
+ [docs/release/0.2.0-known-issues.md](https://github.com/PILOTAAI/slotlock/blob/main/docs/release/0.2.0-known-issues.md).
43
+
8
44
  ## Why it is built for agents
9
45
 
10
46
  - Resources own calendars, not users, and the tenant comes only from the authenticated caller.
@@ -20,15 +56,15 @@ TypeScript library.
20
56
  npm install slotlock postgres
21
57
  ```
22
58
 
23
- Node.js 22.12+, PostgreSQL 16 with `btree_gist` (created for you if missing), and `postgres` ^3.4.5
24
- as a peer dependency. TypeScript declarations are included.
59
+ Node.js 22.12+, PostgreSQL 16 or newer with `btree_gist` (created for you if missing; CI runs 16,
60
+ 17 and 18), and `postgres` ^3.4.5 as a peer dependency. TypeScript declarations are included.
25
61
 
26
62
  ## Self-host with Docker
27
63
 
28
64
  From a clone of this repository:
29
65
 
30
66
  ```sh
31
- cp .env.example .env # set the four empty secrets: openssl rand -hex 32
67
+ scripts/quickstart-env.sh > .env # .env.example, its four secrets set to openssl rand -hex 32
32
68
  docker compose up --build --detach
33
69
  docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/London
34
70
  ```
@@ -37,25 +73,67 @@ docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/
37
73
  | --- | --- |
38
74
  | `http://localhost:8080/mcp` | MCP, with `Authorization: Bearer <SLOTLOCK_AUTH_TOKEN>` |
39
75
  | `http://localhost:8080/a2a` | A2A 1.0, same token; card at `/.well-known/agent-card.json` |
76
+ | `http://localhost:8080/v1/…` | [REST](#rest-and-openapi): the tools, and the [application API](#application-api) for admin keys; described at `/openapi.json` |
40
77
  | `http://localhost:8080/healthz` | Readiness, including the database |
41
78
 
42
79
  - The server runs as a role that owns nothing and cannot bypass row-level security; a one-off
43
80
  `migrate` service applies the schema as the owner.
44
81
  - `SLOTLOCK_AVAILABILITY` sets the default bookable hours. A resource can have its own
45
82
  (`slotlock hours set`, or the dashboard); with neither, no slot is offered.
46
- `SLOTLOCK_CONFIRM_WRITES=all` (the default) makes every write wait for a person.
83
+ `SLOTLOCK_CONFIRM_WRITES=all` (as `.env.example` sets it; the server's default is `none`) makes
84
+ every write wait for a person.
47
85
  - To serve beyond this machine, put a TLS proxy in front and set `SLOTLOCK_PUBLIC_URL`.
86
+ - The server limits requests per client address (before any key is looked up), per key, per
87
+ tenant and for the whole server, and answers `429 rate_limited` with `Retry-After` and the
88
+ `RateLimit` fields. The application API counts an admin key's calls in its tenant's own
89
+ `app_read` and `app_write` budgets, apart from the agents'; another key's are its own. A recurring create and every event update (a new title
90
+ re-expands a recurring event's series too) takes its write token, then waits for a slot: one at
91
+ a time per tenant (`tenant_series_writes`) and per server (`series_writes`); each server process
92
+ counts its own. A freed slot rests for as long as the server was busy while the write before
93
+ held it (at most a second), so with one slot, writes that each hold it under a second keep the
94
+ server busy at most about half the time. It then goes to the waiting tenant with the fewest
95
+ writes in flight and, among those, the one whose writes have kept the server busy least lately
96
+ (halving every ten seconds), then to the first to ask. So a tenant sending one write at a time
97
+ waits for the write running, its rest, and the other tenants with one write in flight whose
98
+ writes kept the server less busy: many such tenants, new ones included, can each go ahead of it
99
+ once. Tenants keeping more writes waiting never go ahead of it. Four more of a tenant's writes
100
+ may wait and the rest get 429. When the server's 64 places are all taken, a write from a tenant
101
+ with fewer writes in flight than the tenant with the most takes the place of that tenant's last
102
+ write, which gets 503 `server_busy`; otherwise it gets 503 at once. An expansion runs on the
103
+ server's main thread, so any request may also wait for the one running. A write that has waited
104
+ two seconds gets 429 while its tenant's own write is still running, otherwise 503
105
+ `server_busy`. An application API maintenance run waits the same way for slots of its own: one
106
+ per tenant, `maintenance_runs` per server. The health check is not counted. One tenant's
107
+ requests hold at most `tenant_transactions` (2) of the server's 10 database connections at once
108
+ and the rest wait their turn, so a tenant's writes waiting on its own locks leave the others to
109
+ other tenants and key lookups; a server with one tenant can set `tenant_transactions=10`. A
110
+ request whose transaction waits more than 5 seconds for a lock gets 503 `server_busy`.
111
+ `SLOTLOCK_LIMITS` changes the limits; behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` to the field it puts the
112
+ client's address in (`CF-Connecting-IP` behind Cloudflare) before the server takes traffic, or
113
+ every client shares the proxy's budget: the server logs a `client_address_field_ignored` warning
114
+ when such a field arrives without the setting. Behind two proxies, such as Cloudflare and then
115
+ kamal-proxy, name the first one's field: the server reads the last address of a list field such
116
+ as `X-Forwarded-For`, which the second proxy writes as Cloudflare's. A client can send that field
117
+ itself, so let nothing reach the server except through the proxy.
118
+ - The server also bounds what one tenant may store, whichever key, agent or application writes
119
+ it: 10,000 holds, and 100,000 each of bookings, calendar events and coverage rows, counted as
120
+ the store's `tenantLimits` count them, besides 10,000 resources. A write past one answers 409
121
+ `hold_limit_reached`, `reservation_limit_reached`, `event_limit_reached` or
122
+ `coverage_limit_reached`; `SLOTLOCK_LIMITS` changes them (`tenant_holds`,
123
+ `tenant_reservations`, `tenant_events`, `tenant_coverage`).
48
124
 
49
125
  ```sh
50
126
  slotlock hours set '[{"rrule":"FREQ=WEEKLY;BYDAY=SA","startMinutes":600,"durationMinutes":240}]' --resource vehicle-42
51
127
  slotlock hours clear --all # back to SLOTLOCK_AVAILABILITY
52
128
  ```
53
129
 
54
- `slotlock --help` lists every setting.
130
+ A rule is `rrule`, `startMinutes` and `durationMinutes` here and in `SLOTLOCK_AVAILABILITY`. The
131
+ REST application API spells every field in snake_case, so there it is `rrule`, `start_minutes` and
132
+ `duration_minutes`. `slotlock --help` lists every setting.
55
133
 
56
134
  ### Without Docker
57
135
 
58
- With Node.js 22.12+ and PostgreSQL 16, run each command as `npx slotlock` after
136
+ With Node.js 22.12+ and PostgreSQL 16 or newer, run each command as `npx slotlock` after
59
137
  `npm install slotlock postgres`, or from a clone, built once, as `node dist/cli.js` (a clone does not
60
138
  put `slotlock` on your `PATH`). Use two roles, as Compose does:
61
139
  the server connects as a role that owns nothing and cannot bypass row-level security, and `migrate`
@@ -85,6 +163,7 @@ Give each agent its own key instead of sharing the server token:
85
163
  ```sh
86
164
  slotlock key create "Booking agent"
87
165
  slotlock key create "Availability bot" --scope read --expires-in-days 90
166
+ slotlock key create "Booking site" --scope read,write,admin
88
167
  slotlock key list
89
168
  slotlock key rotate <id>
90
169
  slotlock key revoke <id>
@@ -92,12 +171,31 @@ slotlock key revoke <id>
92
171
 
93
172
  - A key (`slk_…`) is shown once and stored only as a SHA-256. Send it as `Authorization: Bearer`.
94
173
  - It acts in the tenant it was created for (`SLOTLOCK_TENANT`), so one server serves many tenants.
95
- - `read` covers the five tools that only look; `write` covers create, update and delete. Default:
96
- both.
174
+ - `read` covers the five tools that only look; `write` covers create, update and delete; `admin`
175
+ covers the [application API](#application-api), which no agent needs. Default: read and write.
97
176
  - `rotate` keeps the key's id, scopes and bookings; `revoke` stops it on its next request.
98
177
  - A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can
99
178
  verify offline.
100
179
 
180
+ ### Retiring resources and tenants
181
+
182
+ ```sh
183
+ slotlock resource archive vehicle-42 # no new bookings, holds or events; history kept
184
+ slotlock resource restore vehicle-42
185
+ slotlock resource delete vehicle-42 # with its history; refused while anything on it is live
186
+ slotlock tenant status
187
+ slotlock tenant suspend --reason "chargeback review"
188
+ slotlock tenant unsuspend
189
+ slotlock tenant erase --confirm "$SLOTLOCK_TENANT"
190
+ ```
191
+
192
+ - The resource commands take a reference or an id, and act in `SLOTLOCK_TENANT`.
193
+ - A suspended tenant may read and writes nothing; its keys still authenticate, but it gets no new
194
+ key or secret. Only the schema owner suspends or lifts a suspension, so `suspend` and `unsuspend`
195
+ connect as `SLOTLOCK_MIGRATE_DATABASE_URL`'s role when it is set.
196
+ - `erase` deletes everything the tenant has, its API keys included, at once and for good;
197
+ `--confirm` must repeat the tenant. A suspension outlives it.
198
+
101
199
  ### Dashboard
102
200
 
103
201
  The server can also serve a dashboard where people sign in with GitHub and manage their own keys and
@@ -171,7 +269,7 @@ this one and read its session secret, which signs in as any tenant.
171
269
  - the configuration's id;
172
270
  - your hostname (`routes`);
173
271
  - `SLOTLOCK_PUBLIC_URL`, `SLOTLOCK_SERVICE_URL` and `SLOTLOCK_DASHBOARD_USERS` (`vars`);
174
- - the two rate limiting bindings, each with a `namespace_id` unique in your account.
272
+ - the three rate limiting bindings, each with a `namespace_id` unique in your account.
175
273
  3. Create the GitHub OAuth app as above, with the callback URL
176
274
  `<SLOTLOCK_PUBLIC_URL>/dashboard/callback` on the Worker's origin.
177
275
  4. Store the secrets on the Worker: `npx wrangler secret put` for `SLOTLOCK_GITHUB_CLIENT_ID`,
@@ -183,11 +281,20 @@ this one and read its session secret, which signs in as any tenant.
183
281
  response is sent; Hyperdrive keeps the pool.
184
282
  - Anyone may start a sign-in, and each callback costs a database write and a request to GitHub
185
283
  with the app's client secret. An isolate runs at most eight at once, but Workers run many
186
- isolates, so two rate limiting bindings bound them all:
187
- - `SLOTLOCK_CLIENT_RATE_LIMIT` counts every dashboard request per client address;
188
- - `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts sign-ins and callbacks from every client together.
189
-
190
- Their counts are per Cloudflare location. Without either binding the Worker answers 503.
284
+ isolates, so three rate limiting bindings bound them all:
285
+ - `SLOTLOCK_CLIENT_RATE_LIMIT` counts every dashboard request per client address (an IPv6
286
+ client's /64, as `slotlock serve` counts it);
287
+ - `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts sign-ins and callbacks per client network (an IPv6
288
+ client's /48, a site's allocation, as `slotlock serve` counts its sign-ins), so a site rotating
289
+ through its /64s cannot use up the ceiling;
290
+ - `SLOTLOCK_SIGN_IN_CEILING_RATE_LIMIT` counts them from every client together, set far above one
291
+ client's limit so a few clients cannot use it up for everyone.
292
+
293
+ Their counts are per Cloudflare location. Without either of the first two the Worker answers 503.
294
+ Without the ceiling, as in a configuration from before it, `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts
295
+ every client together, and the Worker logs a warning: add the ceiling before lowering that
296
+ binding's limit to one client's. The example configuration also removes query strings (a
297
+ callback's OAuth code) from the URLs Workers Logs keep.
191
298
  - The server checks a dashboard tenant against `SLOTLOCK_DASHBOARD_USERS` only when it serves the
192
299
  dashboard itself. With the dashboard on Workers, removing a person from the Worker's list signs
193
300
  them out of the dashboard; to stop their keys, revoke them or erase the tenant.
@@ -265,7 +372,8 @@ export const slot = findNextAvailable({
265
372
  // slot: 2026-09-14 10:00–12:00 UTC, the first two free hours after the busy one
266
373
  ```
267
374
 
268
- A rule `expandRules` cannot evaluate adds no availability; it never invents a slot.
375
+ A rule `expandRules` cannot evaluate adds no availability; it never invents a slot. A search window
376
+ may be 367 days at most (`invalid_window`), here and in `findNextAvailableFor`.
269
377
 
270
378
  ## Set up PostgreSQL
271
379
 
@@ -356,7 +464,11 @@ callback call uses, supports nested savepoints, and restores the previous contex
356
464
  standalone `set_config(..., true)` through a pool: its transaction ends before the next call.
357
465
  `withTenant(tenantRef, callback, { isolation: 'read committed' })` runs the callback in a READ
358
466
  COMMITTED transaction whatever the role's default, and refuses inside a caller's transaction of
359
- another level.
467
+ another level. A store sharing one pool among tenants can bound each tenant's share:
468
+ `createSlotlockStore(sql, { maxTenantTransactions, tenantLockTimeoutMs })` runs at most that many
469
+ of a tenant's `withTenant` transactions at once (the rest wait in the process, and leave the line
470
+ when `withTenant`'s `signal` aborts), and refuses one that waits longer than that for a lock with
471
+ `lock_timeout`, safe to retry. `slotlock serve` sets `tenant_transactions` (2) and 5,000.
360
472
 
361
473
  `createResource({ ..., maxTenantResources })` refuses a new resource (`resource_limit_reached`) once
362
474
  the tenant has that many (1-100,000); an existing `externalRef` is still updated. Capped creates for
@@ -411,26 +523,70 @@ export async function scheduleWeeklyInspection(
411
523
  ```
412
524
 
413
525
  Occurrences are stored for a window of at most 367 days and 2,000 occurrences; maintenance rolls
414
- the window forward. Past it, free/busy reports the time as unproven.
415
-
416
- A rule is expanded within a budget: 50,000 steps and a second for a feed (the whole read, from its
417
- text), or for a stored series as maintenance rolls it; half of that for one event as it is written.
418
- A feed's own time zones (VTIMEZONE) count against the same budget. A feed may carry 100 time zones,
419
- 1,000 of their observances and 2,000 RDATE values in them, and 20,000 RDATE and EXDATE values in
420
- its events; a property's name and parameters may take 8 KB and list 100. A SECONDLY to WEEKLY step,
421
- a `DURATION`, or an event's length may be a century at most. A calendar that needs more, such as a
422
- rule that never occurs (`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`), is refused: `invalid_event` on a
423
- write, `invalid_icalendar` from a feed.
424
-
425
- A feed's rules are expanded from their start. A stored series keeps where its last expansion
426
- stopped, and the next one resumes there, so a daily series keeps rolling for as long as it runs:
427
- its budget goes on the window, not on the years before it. A write that changes a series searches
428
- from its start, and resumes from where it stopped only if that search is over the budget. A place
429
- serves only the rule, start and time zone it was kept for, read by the same version of ical.js, and
430
- never lies past an occurrence that could still reach the window (one within the event's length and
431
- a day of it, or one an exception moves into it). Otherwise the series is searched from its start,
432
- within the same budget. During maintenance, a stored series that outgrows the budget is counted as
433
- `refused` (its ids in `refusedEventIds`) and keeps its old coverage.
526
+ the window forward, never moving its end back. Outside it, before or after, free/busy reports the
527
+ time as unproven.
528
+
529
+ A rule is expanded within a budget of steps, a count of work that reads no clock, so the same input
530
+ is accepted or refused however loaded the machine: 500,000 for a feed (the whole read, from its
531
+ text) or for a stored series as maintenance rolls it, about half a second, and 50,000 for one event
532
+ as it is written, refused within a tenth of a second whatever its rule or time zone (on a laptop; a
533
+ 4-vCPU Linux server takes up to twice as long). A step is about a microsecond: a step of ical.js's
534
+ search costs three, a weekday compared one, five days walked one, a year scanned three (and one for
535
+ each eight days it lists), and two weekdays of a year listed one. Each occurrence passed costs one
536
+ more, each conversion of a time in an IANA zone half (one and a half in a zone only the feed
537
+ defines), and each instant Intl is asked for to read a zone's offsets five, the first time an
538
+ expansion reads that day of the zone, whatever the process has read before. A feed's own time zones
539
+ (VTIMEZONE) count against the same budget, each change listed when one is expanded costing four
540
+ tenths: one written the way Outlook writes them, from 1601, costs about 6,000. A feed may carry 100
541
+ time zones and name 100 it does not define, 1,000 observances and 2,000 RDATE values in the zones it
542
+ carries, and 20,000 RDATE and EXDATE values in its events, and an event 8 RRULEs, each further one
543
+ costing a step at every occurrence its expansion passes; a property's name and parameters may take
544
+ 8 KB and list 100. A MINUTELY to WEEKLY step, a `DURATION`, or an event's length may be a century at
545
+ most. A calendar that needs more, such as a rule that never occurs
546
+ (`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`), is refused: `invalid_event` on a write, `invalid_icalendar`
547
+ from a feed.
548
+
549
+ A feed's rules are expanded from their start, and each series may pass 4,000 occurrences on the way
550
+ (a daily one, about 11 years) whatever the feed's other series take. A feed series with one DAILY or
551
+ WEEKLY rule and no COUNT, RDATE or `RANGE=THISANDFUTURE` override is searched from just before the
552
+ window instead, so a daily standup since 2014 reads like one that began this year. A stored series
553
+ keeps where its last expansion stopped, and the next one resumes there, so a daily series keeps
554
+ rolling for as long as it runs: its budget goes on the window, not on the years before it. A write
555
+ that changes a series searches from its start, and resumes from where it stopped only if that search
556
+ is over the budget. A place serves only the rule, start and time zone it was kept for, read by the
557
+ same version of ical.js, and never lies past an occurrence that could still reach the window (one
558
+ within the event's length and a day of it, or one an exception moves into it). Otherwise the series
559
+ is searched from its start, within the same budget. During maintenance, a stored series that
560
+ outgrows the budget, that an earlier release stored and this one refuses, or whose roll fails for
561
+ any other reason (a row no release could have written, such as a rule ical.js cannot read), is
562
+ counted as `refused` (its ids in `refusedEventIds`), keeps its old coverage and waits an hour behind
563
+ the others. A pass whose roll fails still prunes, then reports the failure.
564
+ `storedCalendarEventProblem(event)` says why this
565
+ release would refuse a stored event (a rule shape it no longer expands, `COUNT` with a yearly day
566
+ some years lack, or an exception that names no occurrence), or `null`; run it over your stored
567
+ events before upgrading.
568
+
569
+ A stored rule is daily or less frequent and follows RFC 5545's grammar: `COUNT=0`, an unknown or
570
+ repeated part, or `COUNT` with `UNTIL` is refused. Shapes ical.js 2.2.1 expands differently from
571
+ RFC 5545 are refused everywhere, a feed included, and `recurrenceRuleProblem(rule)` says why:
572
+ `BYWEEKNO`; a two-digit `BYDAY` ordinal; `BYSETPOS` other than with `FREQ=MONTHLY` and `BYDAY`
573
+ alone; `BYMONTH` with `FREQ=MONTHLY`; a negative `BYMONTHDAY` other than with `FREQ=MONTHLY` and
574
+ no `BYDAY`; `FREQ=YEARLY` with `BYMONTHDAY` but no `BYMONTH`, with a day none of its months has,
575
+ with `COUNT` or `BYDAY` and a day one of its months can lack, with `BYYEARDAY` and `BYMONTH` or
576
+ `BYMONTHDAY`, or with a numbered `BYDAY` and `BYMONTHDAY`; `BYHOUR` with `FREQ=HOURLY`,
577
+ `BYMINUTE` or `BYSECOND` with `FREQ=MINUTELY`, and any of them with `FREQ=MONTHLY` or `YEARLY`;
578
+ and `FREQ=SECONDLY`. A yearly day some years lack (29 February, or a 31st with April) occurs only
579
+ in the years that have it, as RFC 5545 says, so its series may not add an RDATE or a second RRULE.
580
+ A DATE `UNTIL` on a timed series includes its whole day on the series' clock. An event's time zone
581
+ is an IANA name, stored in its canonical spelling (`europe/london` becomes `Europe/London`); a UTC
582
+ offset such as `+05:00` is refused.
583
+
584
+ A recurrence exception names the occurrence it cancels or moves by that occurrence's instant, as
585
+ `expandCalendarEventOccurrences` reports it, and is exported as a UTC `RECURRENCE-ID`; one that
586
+ names no occurrence is refused, so a mistyped instant is an error rather than a cancellation that
587
+ frees nothing. A series' occurrences never overlap each other: one that reaches past the next one's
588
+ start (a day-long occurrence the night the clocks go forward) ends there, and the next ends at the
589
+ later of the two ends, so the series holds the same time as separate reservations.
434
590
 
435
591
  ### External calendars
436
592
 
@@ -439,17 +595,59 @@ Slotlock gives them neutral boundaries:
439
595
 
440
596
  - `normalizeExternalCalendarChange` for webhook changes; `parseICalendarChanges` and `emitICalendar`
441
597
  for bounded iCalendar in and out; `emitITipCalendar` for invitations.
598
+ - `parseICalendarFeed` reads a feed series by series: a series it cannot read (a refused rule
599
+ shape, an end the clocks move onto its start, DTEND with DURATION, more than 4,000 occurrences
600
+ from its start, or more work than the feed's budget has left) is named in `refused` by UID, with
601
+ `limit: true` for the last two, and the rest is read. Treat a refused series' time as busy, or the
602
+ feed's coverage as incomplete. `parseICalendarChanges` refuses the whole feed instead.
442
603
  - All-day events become whole local days in the calendar's time zone; a feed without one is refused.
604
+ - A TZID Intl knows is read as that IANA zone, whether or not the feed defines it (a VTIMEZONE's
605
+ own rules for such a name are not used); one only the feed defines, such as a Windows name, is
606
+ read from its VTIMEZONE. A floating time is read in the calendar's zone. So an UNTIL, EXDATE,
607
+ RDATE or RECURRENCE-ID written in UTC or another zone names the occurrence it means, a floating
608
+ UNTIL is read on the series' own clock, and a `RANGE=THISANDFUTURE` exception moves every later
609
+ occurrence by its own shift.
610
+ - An event with no length (a DATE-TIME DTSTART without DTEND or DURATION) occupies no time and is
611
+ left out. A PERIOD RDATE ends where its period does. A document whose root is not a VCALENDAR, or
612
+ an event that ends before it starts, is refused as `invalid_icalendar`.
613
+ - A series' DTSTART is its first occurrence, with or without an RRULE; every RRULE is expanded; an
614
+ instant two of them, or an RRULE and an RDATE, name is one occurrence; every EXDATE excludes the
615
+ occurrence it names (RFC 5545 §3.8.5). A DATE EXDATE of a timed series names the occurrence at the
616
+ series' start time that day.
617
+ - An occurrence of a series occupies both readings of it when the clocks change inside it: its
618
+ exact length from its start (RFC 5545 §3.8.5.3) and its written end on the wall clock (the later
619
+ instant of a repeated hour). 00:00-06:00 holds six hours the night the clocks go forward and
620
+ seven the night they go back. A start in a skipped hour moves forward by the gap; one in a
621
+ repeated hour is its first instant. One-off events, overrides and PERIOD RDATEs occupy exactly
622
+ the times they give. A series' occurrences never overlap each other, as for stored series. SPEC.md,
623
+ "Daylight-saving time", lists the cases.
624
+ - An override without DTEND or DURATION lasts its series' length. One whose RECURRENCE-ID names no
625
+ occurrence, or whose occurrence the series also EXDATEs, occupies its own time, as the provider
626
+ shows it. The instances of a CalDAV-expanded series are each read, the first with or without a
627
+ RECURRENCE-ID. An event with both DTEND and DURATION is refused.
628
+ - `parseTrustedICalendarEvents` reads times as `parseICalendarChanges` does, so a series stored from
629
+ it occupies what the feed reports; an all-day series, stored from local midnight to local
630
+ midnight, may also hold the hour after a midnight the clocks go forward on, never less. It drops
631
+ an EXDATE that names no occurrence, and refuses what a stored series cannot hold: a zone only the
632
+ feed defines (a Windows name such as `GMT Standard Time`; map it to an IANA zone first), a DTSTART
633
+ or DTEND the clocks skip (an all-day series' first midnight included), a `DURATION` that counts
634
+ days or weeks outside UTC, an instance whose transparency differs from its series', or a moved
635
+ instance that names no occurrence.
636
+ - `emitICalendar` and `emitITipCalendar` write a VTIMEZONE for the zone their times name (RFC 5545
637
+ §3.6.5), from the IANA rules: every change over the years the event covers, and, where a zone
638
+ changes on the same numbered or last weekday of a month every year, yearly rules that carry a
639
+ series on past them. A zone that does not, such as Casablanca, Gaza, Jerusalem or Santiago, is
640
+ exact over those years only.
443
641
  - `recordCalendarCoverage` marks how much of a feed is read. Pass the sources a resource depends on as
444
642
  `requiredSources`, and any coverage short of `complete` means the time is not proven free.
445
643
 
446
644
  Read a feed someone else controls with `createICalendarWorkerPool`, which runs the same parsers on
447
645
  worker threads. However costly the feed, the thread that serves requests never runs ical.js on it: a
448
646
  worker past its deadline (3 seconds by default) or its heap (128 MB) is stopped, and the feed is
449
- refused with `CalendarExpansionLimitError`. When every worker is busy and the queue is full, the
450
- pool answers `ICalendarWorkerBusyError` at once. A worker starts in about 40 ms and is reused; idle
451
- workers do not keep the process alive. The pool runs from the built package (the worker is
452
- `ical-worker-thread.js`), not from Slotlock's TypeScript sources.
647
+ refused with `CalendarExpansionLimitError`. The pool offers `parseICalendarFeed` too. When every
648
+ worker is busy and the queue is full, the pool answers `ICalendarWorkerBusyError` at once. A worker
649
+ starts in about 40 ms and is reused; idle workers do not keep the process alive. The pool runs from
650
+ the built package (the worker is `ical-worker-thread.js`), not from Slotlock's TypeScript sources.
453
651
 
454
652
  <!-- example: examples/feeds.ts#feeds -->
455
653
  ```ts
@@ -585,7 +783,7 @@ export async function startCalendarServer(config: CalendarServerConfig) {
585
783
  | `slotlock_list_events` | Events the caller created in a window | read-only |
586
784
  | `slotlock_get_event` | One event the caller created | read-only |
587
785
  | `slotlock_create_event` | Create an event of up to 3,660 days (idempotent) | write |
588
- | `slotlock_update_event` | Patch at an expected revision | write |
786
+ | `slotlock_update_event` | Patch at an expected revision, optionally fenced to `expected_resource_id` | write |
589
787
  | `slotlock_delete_event` | Tombstone at an expected revision | write, destructive |
590
788
 
591
789
  A query window is at most 367 days. Each caller sees only the events it created, but everyone's
@@ -611,7 +809,7 @@ A refusal is a result the model can act on:
611
809
  | `confirmation_required` | The write needs a person, and this client cannot ask one |
612
810
  | `confirmation_declined`, `confirmation_cancelled` | The person said no, or dismissed the question |
613
811
  | `invalid_cursor`, `invalid_event`, `invalid_window`, … | The store rejected a bounded input |
614
- | `request_aborted`, `store_inconsistent` | Transient; retry |
812
+ | `request_aborted`, `server_busy`, `store_inconsistent` | Transient; retry |
615
813
 
616
814
  #### Errors from your own backend
617
815
 
@@ -625,10 +823,10 @@ on stderr.
625
823
 
626
824
  | Where | Error |
627
825
  | --- | --- |
628
- | HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit |
629
- | MCP | `-32602` unknown tool or altered confirmation, `-32603` server fault, `-32601` unknown method |
826
+ | HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit (MCP, REST) |
827
+ | MCP | `-32602` unknown tool, `arguments` that are not an object, or altered confirmation; `-32603` server fault; `-32601` unknown method |
630
828
  | MCP 2026-07-28 | `-32020` header disagrees with body, `-32021` form elicitation not declared, `-32022` unsupported revision, `-31029` rate limit |
631
- | A2A | `-32009` unsupported `A2A-Version`, `-32003` push notifications, `-32004` streaming, `-32001` task not found (the server keeps no tasks) |
829
+ | A2A | `-32602` malformed message or parameters, `-32029` rate limit (`RATE_LIMITED`, in the 200 envelope like every A2A error), `-32009` unsupported `A2A-Version`, `-32003` push notifications, `-32004` streaming, `-32001` task not found (the server keeps no tasks) |
632
830
 
633
831
  ### Authentication and OAuth discovery
634
832
 
@@ -655,6 +853,8 @@ With `confirmation`, a listed write first asks the person a yes/no question that
655
853
  will change, such as `Book vehicle-42 for 2027-03-29 10:00–11:00 (Europe/London): "Vehicle
656
854
  handover".` Only an explicit yes runs the write.
657
855
 
856
+ - A booking that leaves the time free (`transparency: 'transparent'`) says so, and a write names
857
+ the other fields it sets (`also description, attendees`) without quoting them.
658
858
  - Facts come first: the resource by its own reference, times in its own zone, and an event as it
659
859
  stands now, read through your backend's `describeResource` and `getEvent` when the caller may
660
860
  read them (within 2 seconds in all). Otherwise things are named by id, and each time carries its
@@ -665,6 +865,9 @@ handover".` Only an explicit yes runs the write.
665
865
  - The pending question is sealed (HMAC-SHA256) to the caller, the tool and the exact arguments, and
666
866
  expires after `ttlSeconds` (600).
667
867
  - Clients that cannot ask (MCP 2025 revisions, A2A) get `confirmation_required`; nothing is written.
868
+ - The yes comes from the client: Slotlock relies on the MCP client to ask its person, and a key
869
+ holder's own client can answer for itself. Confirmation keeps an agent from writing unasked; it is
870
+ not an authorization boundary. Scopes and `authorize` decide who may write.
668
871
  - `secrets` holds 1 to 8 keys of 32+ bytes: list the new one first to rotate. `onEvent` reports each
669
872
  outcome for audit.
670
873
 
@@ -677,7 +880,13 @@ returns busy time and coverage for the next `resourceWindowDays` (30). `subscrip
677
880
  - Changes are found by re-reading through your backend every `subscriptions.pollIntervalMs` (10 s),
678
881
  re-checking the caller each time.
679
882
  - A subscription ends after `maxDurationMs` (15 minutes), on `shutdown()`, or when access is lost.
680
- - At most 4 open per caller and 256 per server (`maxPerPrincipal`, `maxTotal`).
883
+ - At most 4 open per caller and 256 per server (`maxPerPrincipal`, `maxTotal`). `maxPerTenant`
884
+ caps one tenant's across all its callers (default `maxTotal`; `slotlock serve` sets 16), so a
885
+ tenant with many keys cannot hold every slot. When the server is full, a tenant holding fewer
886
+ than the tenant holding most, less one, ends that tenant's oldest stream instead of being
887
+ refused, so many tenants cannot hold every slot either. `slotlock serve` sets `maxTotal` to
888
+ `subscriptions` (256) and lets one key hold `key_subscriptions` (4), and always fewer than its
889
+ tenant may unless that is one, so one key cannot lock its tenant's other keys out.
681
890
  `subscriptions: false` turns them off.
682
891
 
683
892
  ### Caching and tracing
@@ -685,10 +894,29 @@ returns busy time and coverage for the next `resourceWindowDays` (30). `subscrip
685
894
  2026-07-28 results carry cache hints: discovery and tool lists are public for an hour, free/busy is
686
895
  never cached. W3C `traceparent`, `tracestate` and `baggage` reach your backend as `context.trace`.
687
896
 
897
+ `onEvent` hears about every call that names an operation, so you can count, time and correlate them:
898
+
899
+ - `{ type: 'call', protocol, operation, outcome, durationMs, traceId? }` for an MCP `tools/call` or
900
+ A2A `SendMessage`; `outcome` is `ok`, the refusal's code (`overlap`, `rate_limited`, …), or why
901
+ it was refused before it ran: `invalid_arguments`, `missing_capability` (a guarded write from a
902
+ client that cannot confirm), `invalid_message` or `invalid_message_parameters` (A2A).
903
+ - `{ type: 'rest', operation, status, durationMs, traceId? }` for a REST call, or one refused before
904
+ it ran: a body that is not JSON (415, 400), too large (413) or not an object (400).
905
+ - `{ type: 'confirmation', operation, outcome }` for each confirmation, before the call it decides.
906
+ - A request refused before its operation is read is not reported: a missing credential, the rate
907
+ limit, an `A2A-Version` other than 1.0, a JSON-RPC envelope that does not parse, a path no route
908
+ has.
909
+ - `traceId` is the trace id of a valid `traceparent` from `_meta` or the header. Arguments, callers,
910
+ the parent span, `tracestate` and `baggage` are never reported, and an `onEvent` or `onError` that
911
+ throws, or returns a promise that rejects, changes nothing a client sees. Resource reads and
912
+ subscription polls are not reported.
913
+
914
+ `slotlock serve` writes each as a JSON line (`"event":"call"`, `duration_ms`, `trace_id`).
915
+
688
916
  ### REST and OpenAPI
689
917
 
690
- `rest: true` (`SLOTLOCK_REST_API=on` for `slotlock serve`) also serves each tool as plain HTTP,
691
- described by an OpenAPI 3.1 document at `/openapi.json`:
918
+ `rest: true` also serves each tool as plain HTTP, described by an OpenAPI 3.1 document at
919
+ `/openapi.json`. `slotlock serve` has it on; `SLOTLOCK_REST_API=off` turns it off.
692
920
 
693
921
  | Request | Tool |
694
922
  | --- | --- |
@@ -706,14 +934,96 @@ curl "http://localhost:8080/v1/resources" -H "Authorization: Bearer $SLOTLOCK_AP
706
934
  ```
707
935
 
708
936
  - `GET` and `DELETE` take the tool's arguments as query parameters (a list repeats:
709
- `resource_ids=a&resource_ids=b`); `POST` and `PATCH` take a JSON body. The event id is in the path.
937
+ `resource_ids=a&resource_ids=b`); `POST`, `PUT` and `PATCH` take a JSON body. Ids are in the path;
938
+ percent-encode one that holds `/`.
710
939
  - Keys, scopes, `authorize` and the rate limit apply as over MCP. The body is the tool's output, or
711
940
  `{"error":{"code":…}}` with the matching HTTP status.
712
941
  - REST cannot ask a person, so a write listed in `confirmation` answers `428 confirmation_required`.
942
+ - Free/busy and the next-available search read each resource they name once, however often it is
943
+ named, and 10,000 busy intervals in all; the next-available search also expands 100,000 bookable
944
+ windows from its resources' hours in all. Past either they answer `400 calendar_work_limit`: ask
945
+ for fewer resources or a shorter window.
713
946
  - There are no CORS headers: call it from a server, not a browser.
947
+ - From Python, Go, Rust, Java or .NET, use the [clients](clients/README.md): one method per
948
+ operation, the application API included, each tested against the server in CI. For another
949
+ language, generate one from [`openapi.json`](openapi.json) (or a server's `/openapi.json`, or
950
+ `slotlock/openapi.json` in the npm package) with any OpenAPI 3.1 generator.
951
+
952
+ ### Application API
953
+
954
+ What the application around the agents does, from any language: the store's resource, hours, hold,
955
+ reservation, coverage and maintenance calls, as REST beside the tools. It needs a key with the
956
+ `admin` scope ("Full" in the dashboard); the server's own `SLOTLOCK_AUTH_TOKEN`, the token agents
957
+ are given, reads and writes but is never admin. It is never an MCP tool or an A2A skill, and never
958
+ waits for a confirmation.
959
+
960
+ | Request | Does |
961
+ | --- | --- |
962
+ | `POST /v1/resources` | Create the resource with this `external_ref`, or update its `timezone`; `visibility: "private"` hides it from agents |
963
+ | `GET /v1/resources/{resource_id}` | One resource, with its own weekly hours, visibility and `archived_at` |
964
+ | `POST /v1/resources/{resource_id}/archive` | Take it out of use; `…/restore` puts it back |
965
+ | `DELETE /v1/resources/{resource_id}` | Delete it with its history (`409 resource_in_use` while anything on it is live) |
966
+ | `GET /v1/resources/{resource_id}/busy?start=…&end=…` | Its occupied time, private resources included |
967
+ | `PUT /v1/resources/{resource_id}/availability` | Its own weekly hours; `[]` never bookable, `null` the server's |
968
+ | `PUT /v1/availability` | Every resource's weekly hours at once |
969
+ | `POST /v1/holds` | Hold a window for `ttl_seconds` (up to 7 days) |
970
+ | `GET /v1/holds/{hold_id}` | One live hold; `GET /v1/resources/{resource_id}/holds` lists them |
971
+ | `POST /v1/holds/{hold_id}/confirm` | Turn the hold into a reservation |
972
+ | `DELETE /v1/holds/{hold_id}` | Release the hold |
973
+ | `GET /v1/reservations/{reservation_ref}` | The booking at its current revision |
974
+ | `GET /v1/resources/{resource_id}/reservations?start=…&end=…` | Its bookings in a window |
975
+ | `PUT /v1/reservations/{reservation_ref}` | Book (`revision: 1`) or move (the current revision plus 1) |
976
+ | `DELETE /v1/reservations/{reservation_ref}?revision=…` | Cancel, at the current revision plus 1 |
977
+ | `PUT /v1/resources/{resource_id}/coverage/{source}` | Record that `source`'s events are complete for a window |
978
+ | `GET /v1/tenant/events?resource_id=…&start=…&end=…` | Every key's events on a resource, with the `id` the next two take |
979
+ | `GET /v1/tenant/events/{event_id}` | One of them |
980
+ | `DELETE /v1/tenant/events/{event_id}?expected_revision=…` | Cancel it, whichever key wrote it |
981
+ | `GET /v1/tenant` | The key's tenant, and whether it is suspended |
982
+ | `POST /v1/tenant/erase` | Erase the tenant, when the server allows it |
983
+ | `POST /v1/maintenance` | Run [maintenance](#operations) for the key's tenant |
984
+
985
+ ```sh
986
+ curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
987
+ -H "Authorization: Bearer $SLOTLOCK_ADMIN_KEY" -H "Content-Type: application/json" \
988
+ -d '{"resource_id":"<id>","start":"2027-03-29T09:00:00Z","end":"2027-03-31T09:00:00Z",
989
+ "buffer_after_minutes":60,"revision":1}'
990
+ ```
991
+
992
+ - A taken window answers `409 overlap`. `buffer_after_minutes` keeps the time after a reservation
993
+ busy for holds and other reservations alike.
994
+ - Every write is safe to retry: the same reservation at its current revision answers
995
+ `replayed: true`, a confirmed hold confirms again, and a cancelled reference stays cancelled (a
996
+ late booking for it answers `409`). A late copy of a booking or a move that has since been
997
+ superseded answers `409 stale_revision`.
998
+ - A confirmed hold is the booking whose reference is the hold's id, at revision 1: cancel it with
999
+ `DELETE /v1/reservations/{hold_id}?revision=2`.
1000
+ - `POST /v1/holds` and `DELETE /v1/tenant/events/…` take a retry identity as the `Idempotency-Key`
1001
+ header or as `idempotency_key`. A retried hold answers the hold the first request made; the same
1002
+ key with another request is `409 idempotency_conflict`. Other routes are safe to retry as they
1003
+ are, and refuse the header (`400 idempotency_key_not_supported`).
1004
+ - Lists page by `limit` (up to 100) and `cursor`, the `next_cursor` of the page before, and read a
1005
+ window of at most 367 days.
1006
+ - The time a calendar event occupies is the event's: reservation calls never find, move or cancel
1007
+ it, whatever reference they name. Change the event instead, or cancel it as the tenant with
1008
+ `DELETE /v1/tenant/events/{event_id}`, which needs its revision and an idempotency key, and
1009
+ takes `expected_resource_id` to fence it to the resource you read it on.
1010
+ - Erasing a tenant over the API is off unless the backend is created with `tenantErasure: true`
1011
+ (`slotlock serve` leaves it off: its operator runs `slotlock tenant erase`). It needs
1012
+ `confirm_tenant_ref` to be the tenant's reference (`GET /v1/tenant`), deletes every key the tenant
1013
+ has, the caller's included, and is refused while the tenant is suspended. Suspending a tenant is
1014
+ never an API call: only the schema owner may, and a tenant could not lift its own.
1015
+ - The tenant is the key's. Another tenant's resource or hold is `404`, whatever the database role.
1016
+ - With the library, pass `application` beside `rest: true`:
1017
+ `{ backend: createSlotlockStoreApplicationBackend(store, { maxTenantResources }), authorize }`.
1018
+ A principal with `scopes` needs `admin`; then `authorize` decides.
714
1019
 
715
1020
  ## Connect an agent
716
1021
 
1022
+ MCP and A2A are wire protocols, so an agent in any language connects with its own MCP or A2A client
1023
+ to `/mcp` or `/a2a`, the API key as a bearer token. The examples below use TypeScript. Code that
1024
+ calls Slotlock directly, without an agent, can use the [REST clients](clients/README.md) for
1025
+ Python, Go, Rust, Java and .NET.
1026
+
717
1027
  ### MCP (any client)
718
1028
 
719
1029
  <!-- example: examples/mcp-client.ts#mcp -->
@@ -878,62 +1188,40 @@ and never writes.
878
1188
 
879
1189
  ## Operations
880
1190
 
881
- Run this at least daily for every tenant:
1191
+ Every tenant needs maintenance at least daily: it extends recurring events to the rolling horizon
1192
+ and prunes what the retention window no longer needs, which is what returns agent quota.
1193
+
1194
+ - `slotlock serve` maintains `SLOTLOCK_TENANT` when it starts and every tenant whose key it serves,
1195
+ then each hourly, one tenant at a time. It logs a `maintenance` line with counts.
1196
+ - `slotlock maintain` runs one tenant's maintenance now, for cron or after a bulk import. An admin
1197
+ key can call `POST /v1/maintenance`.
1198
+ - Each pass holds a per-tenant PostgreSQL advisory lock, so servers and cron jobs never repeat each
1199
+ other's work; a pass that finds the lock taken does nothing.
1200
+
1201
+ In a process of your own:
882
1202
 
883
1203
  <!-- example: examples/maintenance.ts#maintenance -->
884
1204
  ```ts
885
- import {
886
- type CalendarHorizonRollCursor,
887
- type CalendarHorizonRollResult,
888
- type SlotlockStore,
889
- calendarEventRollingHorizon,
890
- } from 'slotlock';
891
-
892
- const MAX_BATCHES = 10;
1205
+ import { type SlotlockStore, maintainSlotlockTenant } from 'slotlock';
893
1206
 
894
1207
  export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
895
- const window = calendarEventRollingHorizon();
896
- let extended = 0;
897
- let conflicts = 0;
898
- let refused = 0;
899
- let horizonCapped = true;
900
- let after: CalendarHorizonRollCursor | null = null;
901
- for (let batch = 0; batch < MAX_BATCHES; batch++) {
902
- const from = after;
903
- const roll: CalendarHorizonRollResult = await store.withTenant(tenantRef, (tenant) =>
904
- tenant.rollCalendarEventHorizon({ tenantRef, window, ...(from ? { after: from } : {}) }),
905
- );
906
- extended += roll.extended;
907
- conflicts += roll.conflicts;
908
- refused += roll.refused;
909
- after = roll.next;
910
- if (!after) {
911
- horizonCapped = false;
912
- break;
913
- }
914
- }
915
-
916
- let pruned = 0;
917
- let retentionCapped = true;
918
- for (let batch = 0; batch < MAX_BATCHES; batch++) {
919
- const prune = await store.withTenant(tenantRef, (tenant) =>
920
- tenant.pruneCalendarEventRetention({ tenantRef }),
921
- );
922
- pruned += prune.commandsDeleted + prune.tombstonesDeleted;
923
- if (!prune.hasMore) {
924
- retentionCapped = false;
925
- break;
926
- }
927
- }
928
- return { extended, conflicts, refused, pruned, capped: horizonCapped || retentionCapped };
1208
+ // Extends recurring events to the rolling horizon, then prunes past the retention window, each in
1209
+ // at most ten batches. `capped: true` means work was left: run another pass.
1210
+ return maintainSlotlockTenant(store, tenantRef, { batches: 10 });
929
1211
  }
930
1212
  ```
931
1213
 
932
- - **Run maintenance at least daily for every tenant.** Forced RLS hides other tenants from the
933
- application role, so iterate your own tenant list.
934
- - **Page the roll with `next`.** Pass each batch's `next` as `after` to the next batch of the same
935
- pass. A series left in conflict or refused keeps its place at the front of the order, so without
936
- `after` every later batch would examine it again.
1214
+ - **Every tenant.** Forced RLS hides other tenants from the application role, so iterate your own
1215
+ tenant list. `startSlotlockMaintenance({ store, sql, tenants, intervalMs })` is the scheduler
1216
+ `serve` uses, lock included; `withSlotlockMaintenanceLock` is the lock alone.
1217
+ - **Capped passes.** A capped pass that extended and pruned nothing met only series it cannot extend
1218
+ (counted in `conflicts` and `refused`). The roll puts each behind every series it has not put
1219
+ back, for an hour, so with `rollPending: true` (it stopped before those) run another pass at once:
1220
+ it meets new ones, and a healthy series is reached however many are stuck. Otherwise another pass
1221
+ would meet the same ones, so wait for the next interval.
1222
+ - **Paging the roll yourself.** `rollCalendarEventHorizon` returns `next`; pass it as `after` to the
1223
+ next batch of the same pass, so later batches go on from the last series examined. Series the
1224
+ roll has never put back come first, then those it has, longest waiting first.
937
1225
  - **Quotas.** Each agent principal may retain 1,000 event identities (active plus cancelled) and
938
1226
  10,000 idempotency commands, of which 9,000 can be creates or updates, so a cancel always has
939
1227
  room. Adjust with `agentOwnerEventQuota` and `agentOwnerCommandQuota` on `createSlotlockStore`.
@@ -961,9 +1249,18 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
961
1249
  5. Bound retries on serialization failures; never blindly retry an unknown write.
962
1250
  6. Reconcile external calendars with durable cursors and coverage windows.
963
1251
  7. Monitor conflicts, refused rules, sync lag, capped maintenance runs, quota errors and confirmation outcomes.
964
- 8. Keep writes confirmation-gated wherever your policy needs a person.
1252
+ 8. Keep writes confirmation-gated wherever your policy needs a person (`slotlock serve`:
1253
+ `SLOTLOCK_CONFIRM_WRITES`, `none` by default).
965
1254
  9. Size live updates: at most `maxTotal` backend reads per `pollIntervalMs`.
966
1255
  10. Give each agent its own API key with the narrowest scope and an expiry.
1256
+ 11. After each deployment, check it end to end with a short-lived admin key, then revoke the key:
1257
+ `SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs` checks health,
1258
+ discovery, MCP, A2A, holds and reservations (in a resource named `slotlock-smoke`, 400 days
1259
+ ahead, cancelled after) and the 401, 409, 413 and 415 refusals.
1260
+ 12. Rate-limit every public server. `slotlock serve` does it per client address, key, tenant and
1261
+ server (`SLOTLOCK_LIMITS`); behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` and firewall
1262
+ the server so nothing reaches it except through that proxy. Embedding the
1263
+ agent server, give it a `consumeRateLimit`. Watch serve's `refusals` log line for floods.
967
1264
 
968
1265
  ## Supported versions
969
1266
 
@@ -972,16 +1269,19 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
972
1269
  | MCP | 2026-07-28 (stateless), 2025-11-25, 2025-06-18 |
973
1270
  | A2A | 1.0, JSON-RPC binding |
974
1271
  | MCP Apps | `io.modelcontextprotocol/ui` 2026-01-26 |
975
- | Node.js | ≥ 22.12 (CI: 22 and 24) |
976
- | PostgreSQL | 16 |
1272
+ | Node.js | ≥ 22.12 (CI: 22 and 24; the image runs 24) |
1273
+ | PostgreSQL | 16, 17 and 18 (CI runs the database suites on each; Compose ships 18) |
977
1274
  | postgres (peer) | ^3.4.5 |
978
1275
 
979
1276
  ## Project status and boundaries
980
1277
 
981
- 0.1 is the core: availability maths, holds and reservations, events with recurrence, coverage-aware
1278
+ Slotlock is availability maths, holds and reservations, events with recurrence, coverage-aware
982
1279
  free/busy, tenant isolation, iCalendar interchange, MCP and A2A with human confirmation and live
983
- updates, and an MCP Apps view. Provider sync (Google, Microsoft, CalDAV), notifications to people,
984
- and a human calendar app are yours to build around it.
1280
+ updates, an MCP Apps view, REST with the application API, scoped API keys, a GitHub sign-in
1281
+ dashboard, maintenance, and clients for five languages. [Hosted Slotlock](docs/hosted/README.md)
1282
+ runs it for you. Provider sync (Google, Microsoft, CalDAV), notifications to people, and a human
1283
+ calendar app are yours to build around it. What each version promises is in
1284
+ [VERSIONING.md](VERSIONING.md).
985
1285
 
986
1286
  Events need a finite duration (all-day events are whole local days; one event may last 3,660 days).
987
1287
  Open-ended entries are refused rather than guessed.