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.
- package/CHANGELOG.md +455 -0
- package/CONTRIBUTING.md +55 -19
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +403 -103
- package/SECURITY.md +70 -5
- package/SPEC.md +161 -21
- package/agent-server.d.ts +76 -10
- package/agent-server.js +381 -146
- package/agent-store-backend.d.ts +22 -0
- package/agent-store-backend.js +598 -24
- package/api-keys.d.ts +22 -7
- package/api-keys.js +38 -8
- package/application-api.d.ts +67 -0
- package/application-api.js +552 -0
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +10 -1
- package/dashboard.js +23 -5
- package/ddl.d.ts +47 -4
- package/ddl.js +679 -25
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -8
- package/index.js +7 -5
- package/maintenance.d.ts +67 -0
- package/maintenance.js +213 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +127 -6
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.d.ts +33 -18
- package/rest-api.js +141 -63
- package/rules.d.ts +19 -0
- package/rules.js +38 -7
- package/self-host.d.ts +26 -9
- package/self-host.js +427 -47
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +359 -27
- package/store.js +2077 -296
- package/sync.d.ts +43 -2
- package/sync.js +1950 -277
- package/timezone.d.ts +77 -3
- package/timezone.js +265 -21
- package/types.d.ts +217 -10
- 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
|
|
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
|
-
|
|
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` (
|
|
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
|
-
`
|
|
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
|
|
96
|
-
|
|
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
|
|
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
|
|
187
|
-
- `SLOTLOCK_CLIENT_RATE_LIMIT` counts every dashboard request per client address
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
a
|
|
432
|
-
|
|
433
|
-
|
|
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`.
|
|
450
|
-
pool answers `ICalendarWorkerBusyError` at once. A worker
|
|
451
|
-
workers do not keep the process alive. The pool runs from
|
|
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
|
|
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`
|
|
691
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
- **
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
`
|
|
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
|
-
|
|
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,
|
|
984
|
-
|
|
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.
|