slotlock 0.2.0 → 0.5.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 (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/README.md CHANGED
@@ -5,6 +5,44 @@ 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, released on
14
+ 2026-10-27, fixes all six cases: upgrade (`npm install slotlock@0.3.0` or
15
+ `ghcr.io/pilotaai/slotlock:0.3.0`, then `slotlock migrate`, as the [changelog](CHANGELOG.md) says).
16
+ While you run 0.2.0, do not rely on it alone to refuse bookings in them.
17
+
18
+ - A recurring event whose first occurrence spans a daylight-saving change gets that occurrence an
19
+ hour short: in spring, an hour of the written time is free to book.
20
+ - Recurring events written through the store (the agent tools and the application API) lose the
21
+ repeated hour of an autumn change: times in it move an hour early, and a series whose first
22
+ occurrence spans it can store no occurrences, so all of its time is free.
23
+ - A stored recurring series cannot cancel or move an occurrence that falls in the skipped hour of a
24
+ spring change; the time it was moved to stays free.
25
+ - An occurrence that starts in the skipped hour and lasts longer than the skipped hour is cut short:
26
+ a 75-minute occurrence occupies 15 minutes.
27
+ - A timed recurring series with a date-only `UNTIL` ends at midnight UTC, so it can lose its last
28
+ day.
29
+ - An event read from iCalendar (`parseICalendarChanges`, `parseTrustedICalendarEvents` or the
30
+ worker pool) whose `DURATION` counts hours loses an hour across a spring change, whether it is a
31
+ single event, a series, an `RDATE` period or a moved occurrence: `DURATION:PT48H` occupies 47
32
+ hours.
33
+
34
+ 0.1.0, 0.1.1 and 0.2.0 also pin zod 4.6.5 exactly, which has a high advisory
35
+ ([GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5): validating an oversized
36
+ array can exhaust memory), which `npm audit` reports. While you run one of them, add this to your
37
+ application's `package.json` and run `npm install`:
38
+
39
+ ```json
40
+ "overrides": {"zod": "4.6.7"}
41
+ ```
42
+
43
+ The full notice, with the versions affected, is in
44
+ [docs/release/0.2.0-known-issues.md](https://github.com/PILOTAAI/slotlock/blob/main/docs/release/0.2.0-known-issues.md).
45
+
8
46
  ## Why it is built for agents
9
47
 
10
48
  - Resources own calendars, not users, and the tenant comes only from the authenticated caller.
@@ -20,15 +58,15 @@ TypeScript library.
20
58
  npm install slotlock postgres
21
59
  ```
22
60
 
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.
61
+ Node.js 22.12+, PostgreSQL 16 or newer with `btree_gist` (created for you if missing; CI runs 16,
62
+ 17 and 18), and `postgres` ^3.4.5 as a peer dependency. TypeScript declarations are included.
25
63
 
26
64
  ## Self-host with Docker
27
65
 
28
66
  From a clone of this repository:
29
67
 
30
68
  ```sh
31
- cp .env.example .env # set the four empty secrets: openssl rand -hex 32
69
+ scripts/quickstart-env.sh > .env # .env.example, its four secrets set to openssl rand -hex 32
32
70
  docker compose up --build --detach
33
71
  docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/London
34
72
  ```
@@ -39,24 +77,104 @@ docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/
39
77
  | `http://localhost:8080/a2a` | A2A 1.0, same token; card at `/.well-known/agent-card.json` |
40
78
  | `http://localhost:8080/v1/…` | [REST](#rest-and-openapi): the tools, and the [application API](#application-api) for admin keys; described at `/openapi.json` |
41
79
  | `http://localhost:8080/healthz` | Readiness, including the database |
80
+ | `http://localhost:8080/metrics` | Prometheus metrics, with `Authorization: Bearer <SLOTLOCK_METRICS_TOKEN>`; off without it |
42
81
 
82
+ - Metrics count calls by protocol, operation and outcome (`slotlock_calls_total`, with their
83
+ duration in `slotlock_call_duration_seconds`), confirmations, subscriptions, refusals, maintenance
84
+ and webhook deliveries. No label names a tenant, key or client. `createSlotlockMetrics` builds
85
+ them for a server of your own, from its hooks.
43
86
  - The server runs as a role that owns nothing and cannot bypass row-level security; a one-off
44
87
  `migrate` service applies the schema as the owner.
45
88
  - `SLOTLOCK_AVAILABILITY` sets the default bookable hours. A resource can have its own
46
89
  (`slotlock hours set`, or the dashboard); with neither, no slot is offered.
47
- `SLOTLOCK_CONFIRM_WRITES=all` (the default) makes every write wait for a person.
90
+ `SLOTLOCK_CONFIRM_WRITES=all` (as `.env.example` sets it; the server's default is `none`) makes
91
+ every write wait for a person.
48
92
  - To serve beyond this machine, put a TLS proxy in front and set `SLOTLOCK_PUBLIC_URL`.
93
+ - The server limits requests per client address (before any key is looked up), per key, per
94
+ tenant and for the whole server, and answers `429 rate_limited` with `Retry-After` and the
95
+ `RateLimit` fields. The application API counts an admin key's calls in its tenant's own
96
+ `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
97
+ re-expands a recurring event's series too) takes its write token, then waits for a slot: one at
98
+ a time per tenant (`tenant_series_writes`) and per server (`series_writes`); each server process
99
+ counts its own. A freed slot rests for as long as the server was busy while the write before
100
+ held it (at most a second), so with one slot, writes that each hold it under a second keep the
101
+ server busy at most about half the time. It then goes to the waiting tenant with the fewest
102
+ writes in flight and, among those, the one whose writes have kept the server busy least lately
103
+ (halving every ten seconds), then to the first to ask. So a tenant sending one write at a time
104
+ waits for the write running, its rest, and the other tenants with one write in flight whose
105
+ writes kept the server less busy: many such tenants, new ones included, can each go ahead of it
106
+ once. Tenants keeping more writes waiting never go ahead of it. Four more of a tenant's writes
107
+ may wait and the rest get 429. When the server's 64 places are all taken, a write from a tenant
108
+ with fewer writes in flight than the tenant with the most takes the place of that tenant's last
109
+ write, which gets 503 `server_busy`; otherwise it gets 503 at once. An expansion runs on the
110
+ server's main thread, so any request may also wait for the one running. A write that has waited
111
+ two seconds gets 429 while its tenant's own write is still running, otherwise 503
112
+ `server_busy`. An application API maintenance run waits the same way for slots of its own: one
113
+ per tenant, `maintenance_runs` per server. The health check is not counted. One tenant's
114
+ requests hold at most `tenant_transactions` (2) of the server's 10 database connections at once
115
+ and the rest wait their turn, so a tenant's writes waiting on its own locks leave the others to
116
+ other tenants and key lookups; a server with one tenant can set `tenant_transactions=10`. A
117
+ request whose transaction waits more than 5 seconds for a lock gets 503 `server_busy`.
118
+ With more than one server, `SLOTLOCK_SHARED_LIMITS=on` shares the tenant budgets (`tenant_*`,
119
+ `app_*`) through the database, so N servers allow a tenant its rate once, not N times; each server
120
+ checks its own buckets first, and goes on with them alone if the database cannot be reached.
121
+ `SLOTLOCK_LIMITS` changes the limits; behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` to the field it puts the
122
+ client's address in (`CF-Connecting-IP` behind Cloudflare) before the server takes traffic, or
123
+ every client shares the proxy's budget: the server logs a `client_address_field_ignored` warning
124
+ when such a field arrives without the setting. Behind two proxies, such as Cloudflare and then
125
+ kamal-proxy, name the first one's field: the server reads the last address of a list field such
126
+ as `X-Forwarded-For`, which the second proxy writes as Cloudflare's. A client can send that field
127
+ itself, so let nothing reach the server except through the proxy.
128
+ - The server also bounds what one tenant may store, whichever key, agent or application writes
129
+ it: 10,000 holds, and 100,000 each of bookings, calendar events and coverage rows, counted as
130
+ the store's `tenantLimits` count them, besides 10,000 resources. A write past one answers 409
131
+ `hold_limit_reached`, `reservation_limit_reached`, `event_limit_reached` or
132
+ `coverage_limit_reached`; `SLOTLOCK_LIMITS` changes them (`tenant_holds`,
133
+ `tenant_reservations`, `tenant_events`, `tenant_coverage`).
49
134
 
50
135
  ```sh
51
136
  slotlock hours set '[{"rrule":"FREQ=WEEKLY;BYDAY=SA","startMinutes":600,"durationMinutes":240}]' --resource vehicle-42
52
137
  slotlock hours clear --all # back to SLOTLOCK_AVAILABILITY
53
138
  ```
54
139
 
55
- `slotlock --help` lists every setting.
140
+ A rule is `rrule`, `startMinutes` and `durationMinutes` here and in `SLOTLOCK_AVAILABILITY`. The
141
+ REST application API spells every field in snake_case, so there it is `rrule`, `start_minutes` and
142
+ `duration_minutes`. `slotlock --help` lists every setting.
143
+
144
+ #### Booking rules
145
+
146
+ A resource can also say what a booking on it must satisfy:
147
+
148
+ ```sh
149
+ slotlock rules set '{"minNoticeMinutes":120,"slotStepMinutes":30,"turnaroundMinutes":30,"enforceHours":true}' --resource vehicle-42
150
+ slotlock rules clear --resource vehicle-42
151
+ ```
152
+
153
+ | Rule | A booking made now |
154
+ | --- | --- |
155
+ | `minNoticeMinutes` | starts at least this far ahead |
156
+ | `maxAdvanceDays` | starts at most this far ahead |
157
+ | `slotStepMinutes` | starts on a step from local midnight in the resource's time zone |
158
+ | `turnaroundMinutes` | keeps this long free after it: its trailing buffer, which the database enforces |
159
+ | `enforceHours` | lies inside the resource's bookable hours |
160
+ | `checkInMinutes` | is claimed (`checkInReservation`) this long after its start, or maintenance releases it |
161
+ | `requireCertain` | lies in time every calendar sync recording coverage for the resource has read within a day |
162
+
163
+ - Holds, reservations, moves and agents' events are checked when they are made, by the database's
164
+ clock; a refusal names the rule (`booking_rule_violated` with `rule` in the store,
165
+ `booking_rule_min_notice`, `_max_advance`, `_slot_step`, `_outside_hours` or `_uncertain` to an
166
+ agent). Under `requireCertain`, a search (the store's and the tools') needs those syncs' coverage
167
+ too, read within a day, of the slot and its turnaround, so it offers no slot they leave unproven.
168
+ - A series is judged on its first occurrence for notice and advance, and on every occurrence for
169
+ step and hours. Imported calendar events are not checked: they happened elsewhere.
170
+ - Searches (`slotlock_find_next_available`, `slotlock_find_available_slots`, `findNextAvailableFor`)
171
+ offer only slots the rules allow, so an offered slot is one the write accepts.
172
+ - The REST application API sets them with `PUT /v1/resources/{resource_id}/booking-rules`, in
173
+ snake_case; the store with `setResourceBookingRules`.
56
174
 
57
175
  ### Without Docker
58
176
 
59
- With Node.js 22.12+ and PostgreSQL 16, run each command as `npx slotlock` after
177
+ With Node.js 22.12+ and PostgreSQL 16 or newer, run each command as `npx slotlock` after
60
178
  `npm install slotlock postgres`, or from a clone, built once, as `node dist/cli.js` (a clone does not
61
179
  put `slotlock` on your `PATH`). Use two roles, as Compose does:
62
180
  the server connects as a role that owns nothing and cannot bypass row-level security, and `migrate`
@@ -100,6 +218,25 @@ slotlock key revoke <id>
100
218
  - A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can
101
219
  verify offline.
102
220
 
221
+ ### Retiring resources and tenants
222
+
223
+ ```sh
224
+ slotlock resource archive vehicle-42 # no new bookings, holds or events; history kept
225
+ slotlock resource restore vehicle-42
226
+ slotlock resource delete vehicle-42 # with its history; refused while anything on it is live
227
+ slotlock tenant status
228
+ slotlock tenant suspend --reason "chargeback review"
229
+ slotlock tenant unsuspend
230
+ slotlock tenant erase --confirm "$SLOTLOCK_TENANT"
231
+ ```
232
+
233
+ - The resource commands take a reference or an id, and act in `SLOTLOCK_TENANT`.
234
+ - A suspended tenant may read and writes nothing; its keys still authenticate, but it gets no new
235
+ key or secret. Only the schema owner suspends or lifts a suspension, so `suspend` and `unsuspend`
236
+ connect as `SLOTLOCK_MIGRATE_DATABASE_URL`'s role when it is set.
237
+ - `erase` deletes everything the tenant has, its API keys included, at once and for good;
238
+ `--confirm` must repeat the tenant. A suspension outlives it.
239
+
103
240
  ### Dashboard
104
241
 
105
242
  The server can also serve a dashboard where people sign in with GitHub and manage their own keys and
@@ -115,10 +252,33 @@ resources, so you can give each person API keys without running `slotlock key cr
115
252
  3. Run `slotlock serve` and open `<SLOTLOCK_PUBLIC_URL>/dashboard`. With Compose, set the four in
116
253
  `.env` (empty, the default, leaves the dashboard off) and run `docker compose up --detach`.
117
254
 
255
+ - Its pages: an Overview with the coming week's bookings, events and live holds across every
256
+ resource (each in its resource's time zone) and the latest changes agents made, named by key;
257
+ Resources, each with its own page for bookable hours, booking rules, pool, coming bookings and
258
+ activity; Waitlist, to see who waits and remove an entry; API keys, with the MCP and A2A
259
+ endpoints; Webhooks, to add, delete and re-enable endpoints and see which are failing; and
260
+ Settings.
261
+ Everything on them is read from the tenant's own data. `SLOTLOCK_DASHBOARD_NAME` sets what the
262
+ dashboard calls the deployment (Slotlock by default).
118
263
  - Each person gets a tenant of their own, `github:<user id>`: the keys, resources and bookings in it
119
264
  are theirs alone. Keys made with `slotlock key create` act in `SLOTLOCK_TENANT` instead.
120
- - Sign-in asks GitHub for no scope, uses `state` and PKCE, and keeps nothing from GitHub but the user
121
- id and login: the GitHub token is used once and dropped.
265
+ - **For organizations instead of people**, set `SLOTLOCK_DASHBOARD_ORGS` to the GitHub organization
266
+ ids the dashboard serves (`gh api orgs/<login> --jq .id`). Each is then one tenant,
267
+ `github-org:<id>`, that its members share: keys, resources and hours. A person signs in to the
268
+ served organizations they are an active owner or member of (a billing manager is neither),
269
+ switching between them when there are several; someone in none of them is refused. Every member
270
+ has the same power: each can create, rotate and revoke the organization's keys, and the key list
271
+ says who created each one. Membership is read at each sign-in, so a person who leaves keeps
272
+ the dashboard until their session ends (12 hours at most), and keys they made work until revoked.
273
+ Removing an id from the list signs its sessions out and stops its keys at once. An organization
274
+ that restricts OAuth apps must approve the dashboard's app (its Settings, Third-party access), or
275
+ GitHub hides the membership. `SLOTLOCK_DASHBOARD_USERS` still says who may sign in at all, and a
276
+ key records the person who created it (`github:<user id>`): erasing that person's own tenant
277
+ leaves it on keys they made in an organization, which go with the organization's erasure. Only the
278
+ first 100 memberships GitHub lists, and the first 20 served organizations by login, are read.
279
+ - Sign-in uses `state` and PKCE, and keeps nothing from GitHub but the user id and login (and, with
280
+ organizations, the served ones the person belongs to): the GitHub token is used once and dropped.
281
+ It asks for no scope, or `read:org` with organizations.
122
282
  - A session is a signed, HttpOnly cookie that lasts 12 hours. The allowlist is checked on every
123
283
  request, dashboard and API alike: removing an id and restarting the server signs that person out
124
284
  and stops every key in their tenant at once.
@@ -173,7 +333,7 @@ this one and read its session secret, which signs in as any tenant.
173
333
  - the configuration's id;
174
334
  - your hostname (`routes`);
175
335
  - `SLOTLOCK_PUBLIC_URL`, `SLOTLOCK_SERVICE_URL` and `SLOTLOCK_DASHBOARD_USERS` (`vars`);
176
- - the two rate limiting bindings, each with a `namespace_id` unique in your account.
336
+ - the three rate limiting bindings, each with a `namespace_id` unique in your account.
177
337
  3. Create the GitHub OAuth app as above, with the callback URL
178
338
  `<SLOTLOCK_PUBLIC_URL>/dashboard/callback` on the Worker's origin.
179
339
  4. Store the secrets on the Worker: `npx wrangler secret put` for `SLOTLOCK_GITHUB_CLIENT_ID`,
@@ -185,11 +345,23 @@ this one and read its session secret, which signs in as any tenant.
185
345
  response is sent; Hyperdrive keeps the pool.
186
346
  - Anyone may start a sign-in, and each callback costs a database write and a request to GitHub
187
347
  with the app's client secret. An isolate runs at most eight at once, but Workers run many
188
- isolates, so two rate limiting bindings bound them all:
189
- - `SLOTLOCK_CLIENT_RATE_LIMIT` counts every dashboard request per client address;
190
- - `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts sign-ins and callbacks from every client together.
191
-
192
- Their counts are per Cloudflare location. Without either binding the Worker answers 503.
348
+ isolates, so three rate limiting bindings bound them all:
349
+ - `SLOTLOCK_CLIENT_RATE_LIMIT` counts every dashboard request per client address (an IPv6
350
+ client's /64, as `slotlock serve` counts it);
351
+ - `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts sign-ins and callbacks per client network (an IPv6
352
+ client's /48, a site's allocation, as `slotlock serve` counts its sign-ins), so a site rotating
353
+ through its /64s cannot use up the ceiling;
354
+ - `SLOTLOCK_SIGN_IN_CEILING_RATE_LIMIT` counts them from every client together, set far above one
355
+ client's limit so a few clients cannot use it up for everyone.
356
+
357
+ Their counts are per Cloudflare location. Without either of the first two the Worker answers 503.
358
+ Without the ceiling, as in a configuration from before it, `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts
359
+ every client together, and the Worker logs a warning: add the ceiling before lowering that
360
+ binding's limit to one client's. The example configuration also removes query strings (a
361
+ callback's OAuth code) from the URLs Workers Logs keep.
362
+ - With organizations, set `SLOTLOCK_DASHBOARD_ORGS` on both the Worker and the server: the server
363
+ takes an organization's keys only while its own list names it, whether or not it serves the
364
+ dashboard.
193
365
  - The server checks a dashboard tenant against `SLOTLOCK_DASHBOARD_USERS` only when it serves the
194
366
  dashboard itself. With the dashboard on Workers, removing a person from the Worker's list signs
195
367
  them out of the dashboard; to stop their keys, revoke them or erase the tenant.
@@ -359,7 +531,11 @@ callback call uses, supports nested savepoints, and restores the previous contex
359
531
  standalone `set_config(..., true)` through a pool: its transaction ends before the next call.
360
532
  `withTenant(tenantRef, callback, { isolation: 'read committed' })` runs the callback in a READ
361
533
  COMMITTED transaction whatever the role's default, and refuses inside a caller's transaction of
362
- another level.
534
+ another level. A store sharing one pool among tenants can bound each tenant's share:
535
+ `createSlotlockStore(sql, { maxTenantTransactions, tenantLockTimeoutMs })` runs at most that many
536
+ of a tenant's `withTenant` transactions at once (the rest wait in the process, and leave the line
537
+ when `withTenant`'s `signal` aborts), and refuses one that waits longer than that for a lock with
538
+ `lock_timeout`, safe to retry. `slotlock serve` sets `tenant_transactions` (2) and 5,000.
363
539
 
364
540
  `createResource({ ..., maxTenantResources })` refuses a new resource (`resource_limit_reached`) once
365
541
  the tenant has that many (1-100,000); an existing `externalRef` is still updated. Capped creates for
@@ -372,6 +548,25 @@ uses the default, `[]` closes it); `setTenantAvailability({ tenantRef, rules })`
372
548
  of a tenant. `createSlotlockStoreAgentBackend` answers from a resource's own hours and calls
373
549
  `availabilityRules` only for resources without them.
374
550
 
551
+ Waitlists: `joinWaitlist({ tenantRef, resourceId | pool, start, end, holderRef, offerTtlMs })`
552
+ waits for a window that is taken now. When it comes free (a cancel, a released or expired hold, a
553
+ no-show), `offerWaitlist` gives it to the entries in the order they joined: the first whose window
554
+ is free, on its resource or the first free member of its pool, gets a hold for its holder, and the
555
+ feed says so (`waitlist.offered`, with `hold_id`). Confirm the hold to book it; let it go and the next
556
+ is offered. Cancels, releases and moves, whether an agent (MCP, A2A) or the application API makes
557
+ them, offer at once; maintenance offers what lapses on its own (an expired hold, a no-show) and
558
+ expires entries whose window has started (`waitlist.expired`). With `checkInMinutes` in a
559
+ resource's booking rules, maintenance releases a booking made since the rule was set that nobody
560
+ checked in that long after its start, or after it was made for a walk-in (`reservation.no_show`),
561
+ and the waitlist is offered its time. Check-in opens an hour before the start; moving a booking to
562
+ another start clears it. Leaving the waitlist lets an offer not yet confirmed go with it.
563
+
564
+ Calendar tools speak the standards: `emitVAvailability({ rules, timezone, uid })` writes rules as an
565
+ iCalendar VAVAILABILITY (RFC 7953) and `parseVAvailability(text)` reads one back as
566
+ `{ timezone, rules }`; `toJSCalendarEvent(event)` answers a calendar event as a JSCalendar Event
567
+ (RFC 8984). The API serves them at `/v1/resources/{resource_id}/availability/ical` and
568
+ `/v1/tenant/events/{event_id}/jscalendar`.
569
+
375
570
  Writing rules:
376
571
 
377
572
  - Create with `expectedRevision: 0`; update with the revision you last read and a new idempotency
@@ -417,26 +612,67 @@ Occurrences are stored for a window of at most 367 days and 2,000 occurrences; m
417
612
  the window forward, never moving its end back. Outside it, before or after, free/busy reports the
418
613
  time as unproven.
419
614
 
420
- A rule is expanded within a budget: 50,000 steps and a second for a feed (the whole read, from its
421
- text), or for a stored series as maintenance rolls it; half of that for one event as it is written.
422
- A feed's own time zones (VTIMEZONE) count against the same budget. A feed may carry 100 time zones
423
- and name 100 it does not define, 1,000 observances and 2,000 RDATE values in the zones it carries,
424
- and 20,000 RDATE and EXDATE values in its events; a property's name and parameters may take 8 KB
425
- and list 100. A SECONDLY to WEEKLY step, a `DURATION`, or an event's length may be a century at
615
+ A rule is expanded within a budget of steps, a count of work that reads no clock, so the same input
616
+ is accepted or refused however loaded the machine: 500,000 for a feed (the whole read, from its
617
+ text) or for a stored series as maintenance rolls it, about half a second, and 50,000 for one event
618
+ as it is written, refused within a tenth of a second whatever its rule or time zone (on a laptop; a
619
+ 4-vCPU Linux server takes up to twice as long). A step is about a microsecond: a step of ical.js's
620
+ search costs three, a weekday compared one, five days walked one, a year scanned three (and one for
621
+ each eight days it lists), and two weekdays of a year listed one. Each occurrence passed costs one
622
+ more, each conversion of a time in an IANA zone half (one and a half in a zone only the feed
623
+ defines), and each instant Intl is asked for to read a zone's offsets five, the first time an
624
+ expansion reads that day of the zone, whatever the process has read before. A feed's own time zones
625
+ (VTIMEZONE) count against the same budget, each change listed when one is expanded costing four
626
+ tenths: one written the way Outlook writes them, from 1601, costs about 6,000. A feed may carry 100
627
+ time zones and name 100 it does not define, 1,000 observances and 2,000 RDATE values in the zones it
628
+ carries, and 20,000 RDATE and EXDATE values in its events, and an event 8 RRULEs, each further one
629
+ costing a step at every occurrence its expansion passes; a property's name and parameters may take
630
+ 8 KB and list 100. A MINUTELY to WEEKLY step, a `DURATION`, or an event's length may be a century at
426
631
  most. A calendar that needs more, such as a rule that never occurs
427
632
  (`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`), is refused: `invalid_event` on a write, `invalid_icalendar`
428
633
  from a feed.
429
634
 
430
635
  A feed's rules are expanded from their start, and each series may pass 4,000 occurrences on the way
431
- (a daily one, about 11 years) whatever the feed's other series take. A stored series keeps where
432
- its last expansion stopped, and the next one resumes there, so a daily series keeps rolling for as
433
- long as it runs: its budget goes on the window, not on the years before it. A write that changes a
434
- series searches from its start, and resumes from where it stopped only if that search is over the
435
- budget. A place serves only the rule, start and time zone it was kept for, read by the same version
436
- of ical.js, and never lies past an occurrence that could still reach the window (one within the
437
- event's length and a day of it, or one an exception moves into it). Otherwise the series is searched
438
- from its start, within the same budget. During maintenance, a stored series that outgrows the budget
439
- is counted as `refused` (its ids in `refusedEventIds`) and keeps its old coverage.
636
+ (a daily one, about 11 years) whatever the feed's other series take. A feed series with one DAILY or
637
+ WEEKLY rule and no COUNT, RDATE or `RANGE=THISANDFUTURE` override is searched from just before the
638
+ window instead, so a daily standup since 2014 reads like one that began this year. A stored series
639
+ keeps where its last expansion stopped, and the next one resumes there, so a daily series keeps
640
+ rolling for as long as it runs: its budget goes on the window, not on the years before it. A write
641
+ that changes a series searches from its start, and resumes from where it stopped only if that search
642
+ is over the budget. A place serves only the rule, start and time zone it was kept for, read by the
643
+ same version of ical.js, and never lies past an occurrence that could still reach the window (one
644
+ within the event's length and a day of it, or one an exception moves into it). Otherwise the series
645
+ is searched from its start, within the same budget. During maintenance, a stored series that
646
+ outgrows the budget, that an earlier release stored and this one refuses, or whose roll fails for
647
+ any other reason (a row no release could have written, such as a rule ical.js cannot read), is
648
+ counted as `refused` (its ids in `refusedEventIds`), keeps its old coverage and waits an hour behind
649
+ the others. A pass whose roll fails still prunes, then reports the failure.
650
+ `storedCalendarEventProblem(event)` says why this
651
+ release would refuse a stored event (a rule shape it no longer expands, `COUNT` with a yearly day
652
+ some years lack, or an exception that names no occurrence), or `null`; run it over your stored
653
+ events before upgrading.
654
+
655
+ A stored rule is daily or less frequent and follows RFC 5545's grammar: `COUNT=0`, an unknown or
656
+ repeated part, or `COUNT` with `UNTIL` is refused. Shapes ical.js 2.2.1 expands differently from
657
+ RFC 5545 are refused everywhere, a feed included, and `recurrenceRuleProblem(rule)` says why:
658
+ `BYWEEKNO`; a two-digit `BYDAY` ordinal; `BYSETPOS` other than with `FREQ=MONTHLY` and `BYDAY`
659
+ alone; `BYMONTH` with `FREQ=MONTHLY`; a negative `BYMONTHDAY` other than with `FREQ=MONTHLY` and
660
+ no `BYDAY`; `FREQ=YEARLY` with `BYMONTHDAY` but no `BYMONTH`, with a day none of its months has,
661
+ with `COUNT` or `BYDAY` and a day one of its months can lack, with `BYYEARDAY` and `BYMONTH` or
662
+ `BYMONTHDAY`, or with a numbered `BYDAY` and `BYMONTHDAY`; `BYHOUR` with `FREQ=HOURLY`,
663
+ `BYMINUTE` or `BYSECOND` with `FREQ=MINUTELY`, and any of them with `FREQ=MONTHLY` or `YEARLY`;
664
+ and `FREQ=SECONDLY`. A yearly day some years lack (29 February, or a 31st with April) occurs only
665
+ in the years that have it, as RFC 5545 says, so its series may not add an RDATE or a second RRULE.
666
+ A DATE `UNTIL` on a timed series includes its whole day on the series' clock. An event's time zone
667
+ is an IANA name, stored in its canonical spelling (`europe/london` becomes `Europe/London`); a UTC
668
+ offset such as `+05:00` is refused.
669
+
670
+ A recurrence exception names the occurrence it cancels or moves by that occurrence's instant, as
671
+ `expandCalendarEventOccurrences` reports it, and is exported as a UTC `RECURRENCE-ID`; one that
672
+ names no occurrence is refused, so a mistyped instant is an error rather than a cancellation that
673
+ frees nothing. A series' occurrences never overlap each other: one that reaches past the next one's
674
+ start (a day-long occurrence the night the clocks go forward) ends there, and the next ends at the
675
+ later of the two ends, so the series holds the same time as separate reservations.
440
676
 
441
677
  ### External calendars
442
678
 
@@ -445,6 +681,11 @@ Slotlock gives them neutral boundaries:
445
681
 
446
682
  - `normalizeExternalCalendarChange` for webhook changes; `parseICalendarChanges` and `emitICalendar`
447
683
  for bounded iCalendar in and out; `emitITipCalendar` for invitations.
684
+ - `parseICalendarFeed` reads a feed series by series: a series it cannot read (a refused rule
685
+ shape, an end the clocks move onto its start, DTEND with DURATION, more than 4,000 occurrences
686
+ from its start, or more work than the feed's budget has left) is named in `refused` by UID, with
687
+ `limit: true` for the last two, and the rest is read. Treat a refused series' time as busy, or the
688
+ feed's coverage as incomplete. `parseICalendarChanges` refuses the whole feed instead.
448
689
  - All-day events become whole local days in the calendar's time zone; a feed without one is refused.
449
690
  - A TZID Intl knows is read as that IANA zone, whether or not the feed defines it (a VTIMEZONE's
450
691
  own rules for such a name are not used); one only the feed defines, such as a Windows name, is
@@ -459,21 +700,40 @@ Slotlock gives them neutral boundaries:
459
700
  instant two of them, or an RRULE and an RDATE, name is one occurrence; every EXDATE excludes the
460
701
  occurrence it names (RFC 5545 §3.8.5). A DATE EXDATE of a timed series names the occurrence at the
461
702
  series' start time that day.
462
- - An occurrence ends on the wall clock, as calendars show it (00:30-02:30 lasts three hours the
463
- night the clocks go back). One that starts in an hour the clocks skip lasts its series' exact
464
- length instead, from the offset before the gap (RFC 5545 §3.3.5).
703
+ - An occurrence of a series occupies both readings of it when the clocks change inside it: its
704
+ exact length from its start (RFC 5545 §3.8.5.3) and its written end on the wall clock (the later
705
+ instant of a repeated hour). 00:00-06:00 holds six hours the night the clocks go forward and
706
+ seven the night they go back. A start in a skipped hour moves forward by the gap; one in a
707
+ repeated hour is its first instant. One-off events, overrides and PERIOD RDATEs occupy exactly
708
+ the times they give. A series' occurrences never overlap each other, as for stored series. SPEC.md,
709
+ "Daylight-saving time", lists the cases.
710
+ - An override without DTEND or DURATION lasts its series' length. One whose RECURRENCE-ID names no
711
+ occurrence, or whose occurrence the series also EXDATEs, occupies its own time, as the provider
712
+ shows it. The instances of a CalDAV-expanded series are each read, the first with or without a
713
+ RECURRENCE-ID. An event with both DTEND and DURATION is refused.
465
714
  - `parseTrustedICalendarEvents` reads times as `parseICalendarChanges` does, so a series stored from
466
- it occupies what the feed reports.
715
+ it occupies what the feed reports; an all-day series, stored from local midnight to local
716
+ midnight, may also hold the hour after a midnight the clocks go forward on, never less. It drops
717
+ an EXDATE that names no occurrence, and refuses what a stored series cannot hold: a zone only the
718
+ feed defines (a Windows name such as `GMT Standard Time`; map it to an IANA zone first), a DTSTART
719
+ or DTEND the clocks skip (an all-day series' first midnight included), a `DURATION` that counts
720
+ days or weeks outside UTC, an instance whose transparency differs from its series', or a moved
721
+ instance that names no occurrence.
722
+ - `emitICalendar` and `emitITipCalendar` write a VTIMEZONE for the zone their times name (RFC 5545
723
+ §3.6.5), from the IANA rules: every change over the years the event covers, and, where a zone
724
+ changes on the same numbered or last weekday of a month every year, yearly rules that carry a
725
+ series on past them. A zone that does not, such as Casablanca, Gaza, Jerusalem or Santiago, is
726
+ exact over those years only.
467
727
  - `recordCalendarCoverage` marks how much of a feed is read. Pass the sources a resource depends on as
468
728
  `requiredSources`, and any coverage short of `complete` means the time is not proven free.
469
729
 
470
730
  Read a feed someone else controls with `createICalendarWorkerPool`, which runs the same parsers on
471
731
  worker threads. However costly the feed, the thread that serves requests never runs ical.js on it: a
472
732
  worker past its deadline (3 seconds by default) or its heap (128 MB) is stopped, and the feed is
473
- refused with `CalendarExpansionLimitError`. When every worker is busy and the queue is full, the
474
- pool answers `ICalendarWorkerBusyError` at once. A worker starts in about 40 ms and is reused; idle
475
- workers do not keep the process alive. The pool runs from the built package (the worker is
476
- `ical-worker-thread.js`), not from Slotlock's TypeScript sources.
733
+ refused with `CalendarExpansionLimitError`. The pool offers `parseICalendarFeed` too. When every
734
+ worker is busy and the queue is full, the pool answers `ICalendarWorkerBusyError` at once. A worker
735
+ starts in about 40 ms and is reused; idle workers do not keep the process alive. The pool runs from
736
+ the built package (the worker is `ical-worker-thread.js`), not from Slotlock's TypeScript sources.
477
737
 
478
738
  <!-- example: examples/feeds.ts#feeds -->
479
739
  ```ts
@@ -606,14 +866,46 @@ export async function startCalendarServer(config: CalendarServerConfig) {
606
866
  | `slotlock_list_resources` | Resources visible to the tenant | read-only |
607
867
  | `slotlock_get_free_busy` | Busy intervals plus coverage certainty | read-only |
608
868
  | `slotlock_find_next_available` | Earliest *certain* slot across resources | read-only |
869
+ | `slotlock_find_available_slots` | Up to 50 *certain* slots, earliest first, to offer a choice | read-only |
870
+ | `slotlock_hold_slots` | Hold up to 5 candidates for 30-900 seconds while a person chooses | write |
871
+ | `slotlock_release_slots` | Release a hold and the rest of its group | write |
872
+ | `slotlock_book_together` | Book the same time on 2-20 resources, all or none | write |
873
+ | `slotlock_book_pool` | Book any one free member of a pool (a vehicle class, a room type) | write |
874
+ | `slotlock_get_pool_availability` | How many of a pool's members are free across a window | read |
609
875
  | `slotlock_list_events` | Events the caller created in a window | read-only |
610
876
  | `slotlock_get_event` | One event the caller created | read-only |
611
877
  | `slotlock_create_event` | Create an event of up to 3,660 days (idempotent) | write |
612
- | `slotlock_update_event` | Patch at an expected revision | write |
878
+ | `slotlock_update_event` | Patch at an expected revision, optionally fenced to `expected_resource_id` | write |
613
879
  | `slotlock_delete_event` | Tombstone at an expected revision | write, destructive |
614
880
 
615
881
  A query window is at most 367 days. Each caller sees only the events it created, but everyone's
616
- events count as busy time. The older names `calendar.<verb>` and `calendar_<verb>` still work and
882
+ events count as busy time.
883
+
884
+ An agent offering a person several times holds them first, so none is taken while the person
885
+ decides: `slotlock_hold_slots` holds each free candidate in one group (and lists the taken ones in
886
+ `refused`), and `slotlock_create_event` with one `hold_id` books that time and releases the rest in
887
+ the same transaction. A hold belongs to the agent that took it: another agent can neither book nor
888
+ release it. One agent may have 20 live holds at once, holding at most 2,160 hours together in
889
+ `slotlock serve` (`SLOTLOCK_LIMITS`: `holder_holds`, `holder_held_hours`; `maxHoldsPerHolder` and
890
+ `maxHeldHoursPerHolder` in `createSlotlockStore`); past either it is refused, `hold_limit_reached` or
891
+ `held_time_limit_reached`. Unbooked holds expire by the database clock.
892
+ Notice was judged when the time was held, so booking the hold does not judge it again.
893
+
894
+ A booking that needs several resources at once (a van and its driver, a room and its projector)
895
+ uses `slotlock_find_available_slots` with `require_free` (every resource, or any n of them, free
896
+ together, each judged by its own hours and rules) and books with `slotlock_book_together`: one event
897
+ per resource in one transaction, all or none. A refusal names the `resource_id` that refused, on
898
+ MCP and A2A in the error beside its `code`, on REST in the problem. Bundles take their resources
899
+ in one order, so two racing over the same ones end with one booked, never a deadlock.
900
+
901
+ Rentals sell a class, not a plate. Put interchangeable resources in a pool
902
+ (`PUT /v1/resources/{id}/pool`, `setResourcePool`); agents see each resource's `pool`, list a pool's
903
+ members with `slotlock_list_resources` and `pool`, and book the class with `slotlock_book_pool`,
904
+ which takes the first member that is free and allows the booking, in one transaction: a class of
905
+ five sells exactly five for a time, however many race (`pool_full` after that). To see how many are
906
+ free, `slotlock_get_pool_availability` splits a window where that number changes; to see which,
907
+ search the members with `slotlock_find_available_slots` and `require_free: 1`: each slot names the
908
+ members free in `resource_ids`. `GET /v1/pools` lists the pools with their capacity. The older names `calendar.<verb>` and `calendar_<verb>` still work and
617
909
  are no longer advertised.
618
910
 
619
911
  ### Errors
@@ -635,7 +927,7 @@ A refusal is a result the model can act on:
635
927
  | `confirmation_required` | The write needs a person, and this client cannot ask one |
636
928
  | `confirmation_declined`, `confirmation_cancelled` | The person said no, or dismissed the question |
637
929
  | `invalid_cursor`, `invalid_event`, `invalid_window`, … | The store rejected a bounded input |
638
- | `request_aborted`, `store_inconsistent` | Transient; retry |
930
+ | `request_aborted`, `server_busy`, `store_inconsistent` | Transient; retry |
639
931
 
640
932
  #### Errors from your own backend
641
933
 
@@ -691,6 +983,9 @@ handover".` Only an explicit yes runs the write.
691
983
  - The pending question is sealed (HMAC-SHA256) to the caller, the tool and the exact arguments, and
692
984
  expires after `ttlSeconds` (600).
693
985
  - Clients that cannot ask (MCP 2025 revisions, A2A) get `confirmation_required`; nothing is written.
986
+ - The yes comes from the client: Slotlock relies on the MCP client to ask its person, and a key
987
+ holder's own client can answer for itself. Confirmation keeps an agent from writing unasked; it is
988
+ not an authorization boundary. Scopes and `authorize` decide who may write.
694
989
  - `secrets` holds 1 to 8 keys of 32+ bytes: list the new one first to rotate. `onEvent` reports each
695
990
  outcome for audit.
696
991
 
@@ -703,7 +998,13 @@ returns busy time and coverage for the next `resourceWindowDays` (30). `subscrip
703
998
  - Changes are found by re-reading through your backend every `subscriptions.pollIntervalMs` (10 s),
704
999
  re-checking the caller each time.
705
1000
  - A subscription ends after `maxDurationMs` (15 minutes), on `shutdown()`, or when access is lost.
706
- - At most 4 open per caller and 256 per server (`maxPerPrincipal`, `maxTotal`).
1001
+ - At most 4 open per caller and 256 per server (`maxPerPrincipal`, `maxTotal`). `maxPerTenant`
1002
+ caps one tenant's across all its callers (default `maxTotal`; `slotlock serve` sets 16), so a
1003
+ tenant with many keys cannot hold every slot. When the server is full, a tenant holding fewer
1004
+ than the tenant holding most, less one, ends that tenant's oldest stream instead of being
1005
+ refused, so many tenants cannot hold every slot either. `slotlock serve` sets `maxTotal` to
1006
+ `subscriptions` (256) and lets one key hold `key_subscriptions` (4), and always fewer than its
1007
+ tenant may unless that is one, so one key cannot lock its tenant's other keys out.
707
1008
  `subscriptions: false` turns them off.
708
1009
 
709
1010
  ### Caching and tracing
@@ -740,6 +1041,12 @@ never cached. W3C `traceparent`, `tracestate` and `baggage` reach your backend a
740
1041
  | `GET /v1/resources` | `slotlock_list_resources` |
741
1042
  | `GET /v1/free-busy` | `slotlock_get_free_busy` |
742
1043
  | `GET /v1/next-available` | `slotlock_find_next_available` |
1044
+ | `GET /v1/available-slots` | `slotlock_find_available_slots` |
1045
+ | `POST /v1/slot-holds` | `slotlock_hold_slots` |
1046
+ | `DELETE /v1/slot-holds/{hold_id}` | `slotlock_release_slots` |
1047
+ | `POST /v1/bundles` | `slotlock_book_together` |
1048
+ | `POST /v1/pool-bookings` | `slotlock_book_pool` |
1049
+ | `GET /v1/pool-availability` | `slotlock_get_pool_availability` |
743
1050
  | `GET /v1/events` | `slotlock_list_events` |
744
1051
  | `POST /v1/events` | `slotlock_create_event` |
745
1052
  | `GET /v1/events/{event_id}` | `slotlock_get_event` |
@@ -754,13 +1061,28 @@ curl "http://localhost:8080/v1/resources" -H "Authorization: Bearer $SLOTLOCK_AP
754
1061
  `resource_ids=a&resource_ids=b`); `POST`, `PUT` and `PATCH` take a JSON body. Ids are in the path;
755
1062
  percent-encode one that holds `/`.
756
1063
  - Keys, scopes, `authorize` and the rate limit apply as over MCP. The body is the tool's output, or
757
- `{"error":{"code":…}}` with the matching HTTP status.
1064
+ an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem (`application/problem+json`) with
1065
+ the matching HTTP status: `type` (a tag URI, one per code), `title`, `status`, `code`, and
1066
+ `error.code` as well, the body earlier clients read. An `overlap` names the `resource_id`, `start`
1067
+ and `end` it refused. A `Link: <…>; rel="describedby"` header points at these docs.
1068
+ - A write's `idempotency_key` may come as the `Idempotency-Key` header instead
1069
+ ([draft 08](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)): a
1070
+ quoted Structured Field String (`Idempotency-Key: "booking-BK-42"`) or the bare key, equal to the
1071
+ argument when both are sent. A write that needs one and gets neither answers
1072
+ `400 idempotency_key_required`; the same key with another request, `422 idempotency_conflict`.
1073
+ An event write's key is kept 30 days (or `SLOTLOCK_RETENTION_DAYS`), then removed by maintenance,
1074
+ after which the key is new; a
1075
+ hold's until the hold would have expired, is released or is confirmed.
758
1076
  - REST cannot ask a person, so a write listed in `confirmation` answers `428 confirmation_required`.
1077
+ - Free/busy and the next-available search read each resource they name once, however often it is
1078
+ named, and 10,000 busy intervals in all; the next-available search also expands 100,000 bookable
1079
+ windows from its resources' hours in all. Past either they answer `400 calendar_work_limit`: ask
1080
+ for fewer resources or a shorter window.
759
1081
  - There are no CORS headers: call it from a server, not a browser.
760
1082
  - From Python, Go, Rust, Java or .NET, use the [clients](clients/README.md): one method per
761
1083
  operation, the application API included, each tested against the server in CI. For another
762
- language, generate one from [`openapi.json`](openapi.json) (or a server's `/openapi.json`) with any
763
- OpenAPI 3.1 generator.
1084
+ language, generate one from [`openapi.json`](openapi.json) (or a server's `/openapi.json`, or
1085
+ `slotlock/openapi.json` in the npm package) with any OpenAPI 3.1 generator.
764
1086
 
765
1087
  ### Application API
766
1088
 
@@ -772,16 +1094,27 @@ waits for a confirmation.
772
1094
 
773
1095
  | Request | Does |
774
1096
  | --- | --- |
775
- | `POST /v1/resources` | Create the resource with this `external_ref`, or update its `timezone` |
776
- | `GET /v1/resources/{resource_id}` | One resource, with its own weekly hours |
1097
+ | `POST /v1/resources` | Create the resource with this `external_ref`, or update its `timezone`; `visibility: "private"` hides it from agents |
1098
+ | `GET /v1/resources/{resource_id}` | One resource, with its own weekly hours, visibility and `archived_at` |
1099
+ | `POST /v1/resources/{resource_id}/archive` | Take it out of use; `…/restore` puts it back |
1100
+ | `DELETE /v1/resources/{resource_id}` | Delete it with its history (`409 resource_in_use` while anything on it is live) |
1101
+ | `GET /v1/resources/{resource_id}/busy?start=…&end=…` | Its occupied time, private resources included |
777
1102
  | `PUT /v1/resources/{resource_id}/availability` | Its own weekly hours; `[]` never bookable, `null` the server's |
778
1103
  | `PUT /v1/availability` | Every resource's weekly hours at once |
779
1104
  | `POST /v1/holds` | Hold a window for `ttl_seconds` (up to 7 days) |
1105
+ | `GET /v1/holds/{hold_id}` | One live hold; `GET /v1/resources/{resource_id}/holds` lists them |
780
1106
  | `POST /v1/holds/{hold_id}/confirm` | Turn the hold into a reservation |
781
1107
  | `DELETE /v1/holds/{hold_id}` | Release the hold |
1108
+ | `GET /v1/reservations/{reservation_ref}` | The booking at its current revision |
1109
+ | `GET /v1/resources/{resource_id}/reservations?start=…&end=…` | Its bookings in a window |
782
1110
  | `PUT /v1/reservations/{reservation_ref}` | Book (`revision: 1`) or move (the current revision plus 1) |
783
1111
  | `DELETE /v1/reservations/{reservation_ref}?revision=…` | Cancel, at the current revision plus 1 |
784
1112
  | `PUT /v1/resources/{resource_id}/coverage/{source}` | Record that `source`'s events are complete for a window |
1113
+ | `GET /v1/tenant/events?resource_id=…&start=…&end=…` | Every key's events on a resource, with the `id` the next two take |
1114
+ | `GET /v1/tenant/events/{event_id}` | One of them |
1115
+ | `DELETE /v1/tenant/events/{event_id}?expected_revision=…` | Cancel it, whichever key wrote it |
1116
+ | `GET /v1/tenant` | The key's tenant, and whether it is suspended |
1117
+ | `POST /v1/tenant/erase` | Erase the tenant, when the server allows it |
785
1118
  | `POST /v1/maintenance` | Run [maintenance](#operations) for the key's tenant |
786
1119
 
787
1120
  ```sh
@@ -797,13 +1130,80 @@ curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
797
1130
  `replayed: true`, a confirmed hold confirms again, and a cancelled reference stays cancelled (a
798
1131
  late booking for it answers `409`). A late copy of a booking or a move that has since been
799
1132
  superseded answers `409 stale_revision`.
1133
+ - A confirmed hold is the booking whose reference is the hold's id, at revision 1: cancel it with
1134
+ `DELETE /v1/reservations/{hold_id}?revision=2`.
1135
+ - `POST /v1/holds` and `DELETE /v1/tenant/events/…` take a retry identity as the `Idempotency-Key`
1136
+ header or as `idempotency_key`. A retried hold answers the hold the first request made; the same
1137
+ key with another request is `422 idempotency_conflict`. Other routes are safe to retry as they
1138
+ are, and refuse the header (`400 idempotency_key_not_supported`).
1139
+ - Lists page by `limit` (up to 100) and `cursor`, the `next_cursor` of the page before, and read a
1140
+ window of at most 367 days.
800
1141
  - The time a calendar event occupies is the event's: reservation calls never find, move or cancel
801
- it, whatever reference they name. Change the event instead.
1142
+ it, whatever reference they name. Change the event instead, or cancel it as the tenant with
1143
+ `DELETE /v1/tenant/events/{event_id}`, which needs its revision and an idempotency key, and
1144
+ takes `expected_resource_id` to fence it to the resource you read it on.
1145
+ - Erasing a tenant over the API is off unless the backend is created with `tenantErasure: true`
1146
+ (`slotlock serve` leaves it off: its operator runs `slotlock tenant erase`). It needs
1147
+ `confirm_tenant_ref` to be the tenant's reference (`GET /v1/tenant`), deletes every key the tenant
1148
+ has, the caller's included, and is refused while the tenant is suspended. Suspending a tenant is
1149
+ never an API call: only the schema owner may, and a tenant could not lift its own.
802
1150
  - The tenant is the key's. Another tenant's resource or hold is `404`, whatever the database role.
803
1151
  - With the library, pass `application` beside `rest: true`:
804
1152
  `{ backend: createSlotlockStoreApplicationBackend(store, { maxTenantResources }), authorize }`.
805
1153
  A principal with `scopes` needs `admin`; then `authorize` decides.
806
1154
 
1155
+ ### Changes and webhooks
1156
+
1157
+ Every committed change to a tenant's holds, bookings and calendar events is recorded in the same
1158
+ transaction (by a database trigger, so no write path skips it), and nothing is recorded that did
1159
+ not commit. A change carries ids, times and states, never titles, descriptions or attendees.
1160
+
1161
+ - `GET /v1/changes?cursor=…` lists them oldest first as [CloudEvents 1.0](https://cloudevents.io)
1162
+ (`io.pylota.slotlock.hold.created`, `.reservation.cancelled`, `.event.updated`, …). Follow
1163
+ `next_cursor`, which comes back even when nothing is new: it never skips a change, even one whose
1164
+ transaction commits after a later one's. Changes are kept 30 days (`SLOTLOCK_RETENTION_DAYS`,
1165
+ `retentionDays` in `createSlotlockStore`, 1-3,650, for `serve` and `maintain` alike); a cursor
1166
+ that a pruned change came after, whichever server pruned it, answers `410 cursor_expired`. A change keeps who made
1167
+ it (`actor`) and a booking's or waitlist entry's reference: keep references opaque, not emails,
1168
+ when the feed is kept long. A shorter retention never shortens how long a retried write is
1169
+ answered as the first was: that stays at least 30 days.
1170
+ - `POST /v1/webhooks` with an https `url` (and `event_types`, optionally) sends each change from
1171
+ then on as a [Standard Webhooks](https://www.standardwebhooks.com) request: `webhook-id` (the
1172
+ change's id, the same on every retry), `webhook-timestamp` and `webhook-signature`, keyed by the
1173
+ `whsec_` secret the call returns once. Verify it with any Standard Webhooks library, and use
1174
+ `webhook-id` to drop a repeat: delivery is at least once.
1175
+ - Changes go to each webhook in order, one at a time, until one fails; that one is retried after
1176
+ 5 seconds, 5 minutes, 30 minutes, 2, 5, then every 10 hours. After a day of failures, or a `410`,
1177
+ the webhook is disabled and the feed records `webhook.disabled`; `POST /v1/webhooks/{id}/enable`
1178
+ resumes it where it stopped, or answers `409 changes_pruned` when what it missed is older than the
1179
+ feed keeps (add it again and read what it missed from `/v1/changes`). The feed keeps every change
1180
+ an enabled webhook has still to receive, however old. At most 10 webhooks per tenant.
1181
+ - Each request has 15 seconds in all, and a tenant's pass 30 seconds; tenants are delivered 8 at
1182
+ a time, so a slow endpoint holds up only its own tenant.
1183
+ - History reads the same record backwards, with who made each change (the API key or agent) and,
1184
+ for a change of an existing booking or event, what it was before:
1185
+ `GET /v1/resources/{id}/history`, `GET /v1/reservations/{reference}/history`,
1186
+ `GET /v1/tenant/events/{id}/history`, newest first, paged by `before`. In the library, pass
1187
+ `actor` to `withTenant` and read `listHistory`. It is kept as long as the feed and erased with
1188
+ the tenant.
1189
+ - When time zone rules change (a new tzdata), a booking's instant and the wall time it was made for
1190
+ part: Winnipeg's 09:00 is no longer the instant stored. Slotlock keeps both, and maintenance finds
1191
+ future one-off events whose wall time moved: it reports each once (`event.timezone_shifted`, with
1192
+ the instant it has and the one its wall time now means). The library's `followZoneRules` option
1193
+ to `maintainSlotlockTenant` moves them instead, as a new revision, unless that overlaps another
1194
+ booking (`event.timezone_conflict`; it stays, for a person or an agent to decide). `/healthz`
1195
+ reports the `tzdata` the server runs.
1196
+ - Feed health: `GET /v1/resources/{id}/coverage` shows each source's window, when it was last read,
1197
+ its last error (`POST /v1/resources/{id}/coverage/{source}/failure` records one) and whether it is
1198
+ stale (not read for a day, so the time it covers is unproven, never free). A source that goes stale
1199
+ is put on the feed once, as `coverage.stale`, until it is read again. Retire a sync you have
1200
+ disconnected or replaced with `DELETE /v1/resources/{id}/coverage/{source}` (`deleteCoverage`):
1201
+ nothing waits on it after that, `requireCertain` included.
1202
+ - `slotlock serve` delivers (`SLOTLOCK_WEBHOOKS`): never to an address on a private network, which
1203
+ it checks when it connects, and following no redirect. `private` allows those, for testing.
1204
+ - In the library: `listChanges`, `createWebhookEndpoint` and the rest are store methods, and
1205
+ `startSlotlockWebhookDelivery` with `deliverSlotlockWebhooks` runs delivery in your own process.
1206
+
807
1207
  ## Connect an agent
808
1208
 
809
1209
  MCP and A2A are wire protocols, so an agent in any language connects with its own MCP or A2A client
@@ -912,7 +1312,7 @@ export async function openLiveCalendar(options: LiveCalendarOptions) {
912
1312
 
913
1313
  ```sh
914
1314
  claude mcp add --transport http slotlock https://calendar.example.com/slotlock/mcp \
915
- --header "Authorization: Bearer $SLOTLOCK_TOKEN"
1315
+ --header "Authorization: Bearer $SLOTLOCK_API_KEY"
916
1316
  ```
917
1317
 
918
1318
  **Claude API** (MCP connector, `anthropic-beta: mcp-client-2025-11-20`; the server must be public
@@ -973,6 +1373,18 @@ Read-only tools link to `ui://slotlock/calendar`, a self-contained calendar view
973
1373
  supporting MCP Apps (`io.modelcontextprotocol/ui`) render in a sandbox. It makes no network requests
974
1374
  and never writes.
975
1375
 
1376
+ A person can pick a slot in it. After `slotlock_find_available_slots` or a certain
1377
+ `slotlock_find_next_available`, the view shows a strip of days and each day's times by morning,
1378
+ afternoon and evening, or the one next available time. Picking a time and pressing *Book this time*
1379
+ sends one chat message naming the resource id and the slot's start and end. The agent books it with
1380
+ `slotlock_create_event`, through confirmation if `SLOTLOCK_CONFIRM_WRITES` asks for it. The message
1381
+ carries nothing else from the result. If the host refuses the message, the view shows the text to
1382
+ send instead. Write tools are marked `visibility: ["model"]`, so a host never lets a view call them.
1383
+
1384
+ The view takes its look from the host: its light or dark theme and its standard MCP Apps style
1385
+ variables (colours, fonts, sizes). It shows times in the person's locale, clock and time zone, with
1386
+ a 12h/24h switch.
1387
+
976
1388
  ## Operations
977
1389
 
978
1390
  Every tenant needs maintenance at least daily: it extends recurring events to the rolling horizon
@@ -1002,10 +1414,13 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1002
1414
  tenant list. `startSlotlockMaintenance({ store, sql, tenants, intervalMs })` is the scheduler
1003
1415
  `serve` uses, lock included; `withSlotlockMaintenanceLock` is the lock alone.
1004
1416
  - **Capped passes.** A capped pass that extended and pruned nothing met only series it cannot extend
1005
- (counted in `conflicts` and `refused`): another pass would too, so wait for the next interval.
1417
+ (counted in `conflicts` and `refused`). The roll puts each behind every series it has not put
1418
+ back, for an hour, so with `rollPending: true` (it stopped before those) run another pass at once:
1419
+ it meets new ones, and a healthy series is reached however many are stuck. Otherwise another pass
1420
+ would meet the same ones, so wait for the next interval.
1006
1421
  - **Paging the roll yourself.** `rollCalendarEventHorizon` returns `next`; pass it as `after` to the
1007
- next batch of the same pass. A series left in conflict or refused keeps its place at the front of
1008
- the order, so without `after` every later batch would examine it again.
1422
+ next batch of the same pass, so later batches go on from the last series examined. Series the
1423
+ roll has never put back come first, then those it has, longest waiting first.
1009
1424
  - **Quotas.** Each agent principal may retain 1,000 event identities (active plus cancelled) and
1010
1425
  10,000 idempotency commands, of which 9,000 can be creates or updates, so a cancel always has
1011
1426
  room. Adjust with `agentOwnerEventQuota` and `agentOwnerCommandQuota` on `createSlotlockStore`.
@@ -1016,12 +1431,14 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1016
1431
  (provider and sync authority) are kept until you erase the tenant. After the window a retried
1017
1432
  command is evaluated afresh under its expected revision, and a pruned agent identity can be
1018
1433
  created again. `retentionDays` (1-3650) changes the window; keep it longer than any client retries.
1019
- - **Erasure.** Include Slotlock resources, reservations, tombstones, archives, event content,
1020
- occurrences, commands and coverage rows in your tenant-erasure workflow and delete order, and
1021
- delete the tenant's API keys with `createSlotlockApiKeyStore(sql).erase({ tenantRef })`. Only
1022
- their SHA-256 digests stay, with nothing about the tenant, so an erased key is never accepted
1023
- again. The dashboard's records hold no tenant and expire within 12 hours; a form record carries a
1024
- SHA-256 digest of the GitHub user id that sent it.
1434
+ - **Erasure.** `slotlock tenant erase --confirm <tenant>`, or the store's `eraseTenant({ tenantRef })`,
1435
+ deletes everything the tenant owns in one transaction, waiting for its transactions in flight:
1436
+ resources, holds, bookings, events with their attendees and reminders, cancellations,
1437
+ idempotency records, coverage, API keys, and a personal tenant's dashboard records. Call it from
1438
+ your own erasure workflow; nothing is deleted table by table. Only the API keys' SHA-256 digests
1439
+ stay, with nothing about the tenant, so an erased key is never accepted again. The dashboard's
1440
+ other records hold no tenant and expire within 12 hours; a form record carries a SHA-256 digest
1441
+ of the GitHub user id that sent it, and an accepted write confirmation a digest of its nonce.
1025
1442
 
1026
1443
  ### Production checklist
1027
1444
 
@@ -1033,13 +1450,26 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1033
1450
  5. Bound retries on serialization failures; never blindly retry an unknown write.
1034
1451
  6. Reconcile external calendars with durable cursors and coverage windows.
1035
1452
  7. Monitor conflicts, refused rules, sync lag, capped maintenance runs, quota errors and confirmation outcomes.
1036
- 8. Keep writes confirmation-gated wherever your policy needs a person.
1453
+ 8. Keep writes confirmation-gated wherever your policy needs a person (`slotlock serve`:
1454
+ `SLOTLOCK_CONFIRM_WRITES`, `none` by default).
1037
1455
  9. Size live updates: at most `maxTotal` backend reads per `pollIntervalMs`.
1038
1456
  10. Give each agent its own API key with the narrowest scope and an expiry.
1039
1457
  11. After each deployment, check it end to end with a short-lived admin key, then revoke the key:
1040
- `SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs` checks health,
1458
+ `SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs`, from a clone
1459
+ of the repository, checks health,
1041
1460
  discovery, MCP, A2A, holds and reservations (in a resource named `slotlock-smoke`, 400 days
1042
1461
  ahead, cancelled after) and the 401, 409, 413 and 415 refusals.
1462
+ 12. Rate-limit every public server. `slotlock serve` does it per client address, key, tenant and
1463
+ server (`SLOTLOCK_LIMITS`); behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` and firewall
1464
+ the server so nothing reaches it except through that proxy. Embedding the
1465
+ agent server, give it a `consumeRateLimit`. Watch serve's `refusals` log line for floods.
1466
+ 13. Bound how long a transaction may run. A store over your own transaction (a savepoint per call)
1467
+ keeps its locks and its snapshot for as long as yours runs, and a hold you confirm inside it is
1468
+ judged by the statement's clock, not the transaction's. On PostgreSQL 17 and later, give the
1469
+ serving role a `transaction_timeout` above your longest legitimate transaction
1470
+ (`ALTER ROLE slotlock_app SET transaction_timeout = '60s'`): PostgreSQL ends one that runs
1471
+ longer, rolling back what it did, and closes its connection (the pool opens another). On 16, set `idle_in_transaction_session_timeout`
1472
+ and `statement_timeout`, which bound idle gaps and single statements but not the whole.
1043
1473
 
1044
1474
  ## Supported versions
1045
1475
 
@@ -1048,16 +1478,19 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1048
1478
  | MCP | 2026-07-28 (stateless), 2025-11-25, 2025-06-18 |
1049
1479
  | A2A | 1.0, JSON-RPC binding |
1050
1480
  | MCP Apps | `io.modelcontextprotocol/ui` 2026-01-26 |
1051
- | Node.js | ≥ 22.12 (CI: 22 and 24) |
1052
- | PostgreSQL | 16 |
1481
+ | Node.js | ≥ 22.12 (CI: 22 and 24; the image runs 24) |
1482
+ | PostgreSQL | 16, 17 and 18 (CI runs the database suites on each; Compose ships 18) |
1053
1483
  | postgres (peer) | ^3.4.5 |
1054
1484
 
1055
1485
  ## Project status and boundaries
1056
1486
 
1057
- 0.1 is the core: availability maths, holds and reservations, events with recurrence, coverage-aware
1487
+ Slotlock is availability maths, holds and reservations, events with recurrence, coverage-aware
1058
1488
  free/busy, tenant isolation, iCalendar interchange, MCP and A2A with human confirmation and live
1059
- updates, and an MCP Apps view. Provider sync (Google, Microsoft, CalDAV), notifications to people,
1060
- and a human calendar app are yours to build around it.
1489
+ updates, an MCP Apps view, REST with the application API, scoped API keys, a GitHub sign-in
1490
+ dashboard, maintenance, and clients for five languages. [Hosted Slotlock](docs/hosted/README.md),
1491
+ not open yet, will run it for you. Provider sync (Google, Microsoft, CalDAV), notifications to people, and a human
1492
+ calendar app are yours to build around it. What each version promises is in
1493
+ [VERSIONING.md](VERSIONING.md).
1061
1494
 
1062
1495
  Events need a finite duration (all-day events are whole local days; one event may last 3,660 days).
1063
1496
  Open-ended entries are refused rather than guessed.