slotlock 0.5.2 → 0.6.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 CHANGED
@@ -3,6 +3,200 @@
3
3
  All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic
4
4
  versioning.
5
5
 
6
+ ## 0.6.0 - 2026-10-29
7
+
8
+ ### Added
9
+
10
+ - `invalid_arguments` from argument validation names the fields it refused: `issues`, up to 20
11
+ `{path, message}` (the field's path in the arguments, outermost first; `[]` is the arguments as a
12
+ whole). REST adds it to the problem details beside `code` (documented in openapi.json's `Error`),
13
+ MCP and A2A to `error`. Additive: `code` and the status are unchanged.
14
+ `invokeSlotlockAgentOperation` returns it too (`SlotlockArgumentIssue`).
15
+ - `slotlock serve` logs one `serving_as_schema_owner` warning at startup when its role owns the
16
+ `slotlock` schema: row-level security does not confine the owner on tables that do not force
17
+ it. It still serves.
18
+
19
+ ### Fixed
20
+
21
+ - Security: a coverage source's `source_id` could be confirmed by guessing. 0.5.3 named a source on the feed
22
+ by `src_` and 24 hex digits of an unkeyed SHA-256 of the tenant and the source, so anyone who saw
23
+ a change or a webhook and knew the tenant could test a guessed source offline: an address such as
24
+ `google:alice.smith@example.com`, say. A source's `source_id` is now random (`src_` and 32 hex
25
+ digits, 122 bits from `gen_random_uuid()`), stored with the source in
26
+ `calendar_coverage.source_id` when it is first recorded for the resource and kept until it is
27
+ deleted. Nothing outside the database can work it out; the tenant reads it beside the source from
28
+ `listCoverage` and `slotlock_get_coverage`.
29
+ - Security: changes an earlier release wrote still carried the source. Before 0.5.3 `coverage.stale` had the
30
+ source itself as `subject` and `data.source` (an iCalendar URL with its token) and the error read
31
+ from it as `data.last_error`, and `listChanges`, the history and webhooks that had not received it
32
+ served it until it was pruned. The upgrade renames every `coverage.*` change not in this release's
33
+ shape: `subject` and `data.source_id` become the source's new id, `source` and `last_error` are
34
+ removed. A change about a source deleted since is named `src_redacted`: nothing can match it any
35
+ more, and a made-up id would look like a source the tenant could find. A change an earlier
36
+ release's server (0.3-0.5) still running writes after the upgrade is put in the same shape as it
37
+ is inserted, by a trigger on the outbox (`slotlock.outbox_coverage_in_shape`), so none is stored
38
+ with its source. The read path redacts too: `listChanges`, `listRecentChanges`, `listHistory`,
39
+ webhook deliveries and `slotlockCloudEvent` never serve a `coverage.*` change with `source` or
40
+ `last_error`, and name one whose subject is not a `source_id` of this release `src_redacted`;
41
+ history by `subject` never finds a coverage change by any other name, so it cannot confirm a
42
+ guessed source either.
43
+
44
+ ### Changed
45
+
46
+ - **Breaking:** every `source_id` changes, and its shape is `src_` and 32 hex digits (was 24). A
47
+ reader that stored `source_id`s or `subject`s of `coverage.*` changes matches them again from
48
+ `listCoverage` / `GET /v1/resources/{id}/coverage`. The same source recorded for two resources,
49
+ or deleted and recorded again, has a different id each time.
50
+ - **Breaking:** changes already on the feed about coverage are rewritten in place by the upgrade, as
51
+ above; their positions and ids (`webhook-id`) do not change, so a webhook that already received
52
+ one is not sent it again, and one that has not receives the new shape.
53
+
54
+ - **Breaking:** `slotlockCoverageSourceId` throws (`coverage_source_id_unavailable`) instead of
55
+ returning 0.5.3's digest, which nothing carries any more and which would confirm a guessed source:
56
+ read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`) instead.
57
+ - `slotlock migrate` (and `serve --migrate`) run as a role that does not own the schema, with no
58
+ `SLOTLOCK_MIGRATE_DATABASE_URL`, now fail with a first line that says to set
59
+ `SLOTLOCK_MIGRATE_DATABASE_URL` to the schema owner's URL, then the database's refusal.
60
+ - The README no longer opens with the 0.2.0 known issues: they are in
61
+ `docs/release/0.2.0-known-issues.md`, linked under "Supported versions".
62
+ - The hosted service page, its site page and SECURITY.md say the hosted service is not open and
63
+ that self-hosting is the only supported path today; SECURITY.md no longer invites testing it.
64
+
65
+ ### Deprecated
66
+
67
+ - `slotlockCoverageSourceId`, which now only throws. It may be removed in 0.7.0.
68
+
69
+ ### Upgrading
70
+
71
+ - **Breaking:** the schema is version 4. A 0.5 release refuses to migrate it ("Slotlock will not
72
+ migrate a schema a newer release migrated") and its server refuses to serve it, since its DDL would
73
+ put back the trigger that names a source by its guessable digest. Rolling back to 0.5.x needs a
74
+ restore of a backup taken before the upgrade.
75
+ - The migration adds `calendar_coverage.source_id` without rewriting the table (the column bare, its
76
+ random default set apart), gives each existing source an id under the migration's lock
77
+ (`calendar_coverage` is one row per source and resource), and adds its CHECK NOT VALID, validated
78
+ after the migration commits.
79
+ - Changes written from the upgrade on are put in shape as they are inserted. Those written before
80
+ it: once the structure commits, `applySchema` renames them in batches of 5,000 outbox ids by
81
+ primary key, each its own transaction, up to the last change when the pass started, read in a
82
+ short transaction of its own once the outbox writes in flight have ended. Where it got to is saved
83
+ with each batch (`schema_version.coverage_changes_renamed_through`): an interrupted pass goes on
84
+ from there, and the next migration reads only what was written since, until a pass after the
85
+ upgrade's finds nothing to rename and marks it done (`coverage_changes_renamed`). After that no
86
+ migration reads the outbox for it again.
87
+ - Locks: under a deployment role that forced RLS binds, each batch lifts FORCE from
88
+ `calendar_coverage` and `outbox` and puts it back before it commits, holding ACCESS EXCLUSIVE on
89
+ both for that batch (one range of 5,000 ids: milliseconds), so writes to either table wait that
90
+ long, once per batch, for every batch of the outbox on the upgrade and for the rows written since
91
+ on the passes after it. A superuser or BYPASSRLS role holds SHARE ROW EXCLUSIVE on the outbox
92
+ for the short transaction that reads the head (writes wait, reads do not), then only the row
93
+ locks of the rows it renames. `SLOTLOCK_CORE_DDL`, applied with psql, renames in its one transaction instead, with
94
+ the outbox under ACCESS EXCLUSIVE for the whole of it: a scan of the whole outbox on the upgrade.
95
+ - A migration step ended as a deadlock's victim (a caller's transaction that took the outbox, then a
96
+ coverage row, while a rename batch took them in the other order, say) is retried like one that
97
+ waited past its lock timeout, up to `attempts`, instead of failing.
98
+
99
+ ## 0.5.3 - 2026-10-28
100
+
101
+ 0.5.3 shipped breaking changes in a patch release, contrary to [VERSIONING.md](VERSIONING.md): the
102
+ two marked "Breaking" under **Changed**. Breaking changes will be minor releases from now on.
103
+
104
+ ### Added
105
+
106
+ - Configuration changes on the feed and in history (RFC 0002 P1-5, P1-6), each in the change's own
107
+ transaction, by trigger: `resource.created`, `resource.updated` (booking and check-in rules,
108
+ pool, bookable hours, time zone or reference, with `before` and `after` of the fields that
109
+ changed), `resource.archived`, `resource.restored`, `resource.deleted`, `coverage.recorded` (a
110
+ source's first read) and `coverage.deleted`, `webhook.created`, `webhook.enabled` and
111
+ `webhook.deleted`, and `key.created`, `key.rotated` and `key.revoked`. Writing a value that is
112
+ already there records nothing. Rows carry ids and states: never a webhook's URL or secret, nor an
113
+ API key, its digest, prefix or name, nor a coverage source, which `coverage.*` changes name by
114
+ `source_id` (`src_` and 24 hex digits, a digest of the tenant and the source; `subject` too).
115
+ `slotlock_get_coverage` and `listCoverage` give each source's `source_id`, and
116
+ `slotlockCoverageSourceId` works it out. A webhook's `event_types` takes any of them, up to every
117
+ type (it took at most 20).
118
+ - Where a change came from: `withTenant` takes `origin` (`mcp`, `a2a`, `rest`, `application`,
119
+ `dashboard`, `dashboard-assistant`, `maintenance` or `library`, the default, also for a write
120
+ outside `withTenant`) and `keyId`, set by
121
+ each surface: the agent server per protocol, the application API, the dashboard and its
122
+ assistant, maintenance and webhook delivery. API key principals carry `keyId`, and the API key
123
+ store's `create`, `rotate` and `revoke` take `actor` and `origin`. The feed's new nullable
124
+ columns `origin` and `key_id` are in `listHistory` and `listChanges` (`origin`, `keyId`), the
125
+ history API (`origin`, `key_id`) and CloudEvents (extension attributes `slotlockorigin` and
126
+ `slotlockkeyid`). Rows written before keep null; an origin this release does not know reads as
127
+ null. `key_id` is pseudonymous and stays on a revoked key's changes for the retention. No address
128
+ or other personal data is recorded. The column's CHECK is added NOT VALID and validated after the
129
+ migration commits, so upgrading scans no rows under the table lock. An authenticator's key id
130
+ that is not a version 1-5 UUID is left out, as documented, rather than refusing the call.
131
+ - Live updates from the change feed (RFC 0002 P1-5). With `subscriptions.changes`
132
+ (`createSlotlockStoreChangeSource(store, sql)`, which `slotlock serve` sets), MCP resource
133
+ subscriptions follow each tenant's change feed instead of re-reading every 10 seconds: one reader
134
+ per tenant per process, shared by its subscriptions, polls the feed by cursor (one indexed query,
135
+ every 250 ms while changes come, `SLOTLOCK_FEED_BUSY_MS`, backing off to 1 s when quiet,
136
+ `SLOTLOCK_FEED_IDLE_MS`) and re-reads only the subscriptions a change touches, each at most once a
137
+ second, so a booking is announced within about a second. Nothing is added to the write path: no
138
+ trigger, no `NOTIFY`. A cursor pruned past announces every watched resource once. The re-read every
139
+ `pollIntervalMs` stays as the fallback and revalidates the caller (15 s with a feed), and the
140
+ subscription caps are unchanged. `slotlockChangeFeedHead` is where a reader starts.
141
+ - A2A streaming: the card declares `streaming`. `SendStreamingMessage` streams any skill's reply as
142
+ one message, and the `slotlock_watch_resources` skill streams a watch task with a status update
143
+ for each change to a watched resource's free/busy, from the same feed and caps as MCP
144
+ subscriptions, ending `TASK_STATE_COMPLETED` or, when access is withdrawn, `TASK_STATE_FAILED`.
145
+ Push notifications stay unsupported: webhooks deliver a tenant's changes to a URL.
146
+ - Benchmarks on every release (RFC 0002, P2-1): once a tag's suite passes, release.yml runs
147
+ `scripts/bench.mjs` against a PostgreSQL 16 service container, attaches `bench.json` and
148
+ `bench.md` to the GitHub release and rebuilds the site, whose new Benchmarks page shows the newest
149
+ results with the method and caveats. The job is informational: nothing waits on it and its failure
150
+ fails no release. The script now also measures free/busy reads and the non-overlapping workload
151
+ with durable commits off, records the PostgreSQL version, CPU count, clients and duration, and
152
+ writes both files with `--out DIR`.
153
+ - Booking rules: a leading buffer (`bufferBeforeMinutes`), the time kept free before each booking,
154
+ so two bookings stay the earlier one's trailing buffer plus the later one's leading buffer apart.
155
+ A reservation now occupies from `occupied_starts_at` (its start less `buffer_before_ms`, set by
156
+ a trigger) and the `slotlock_reservations_no_overlap` constraint keeps that time apart; free/busy,
157
+ searches, pool availability, holds and their reaping, coverage checks, series rolls and the
158
+ waitlist all read it.
159
+ - Booking rules: a step offset (`stepOffsetMinutes`, 0 to the step less one): with a 30-minute step
160
+ and 15, bookings start on :15 and :45, local time in the resource's zone, on writes and searches.
161
+ - Default booking rules for the tenant and for each pool (`setBookingRuleDefaults`,
162
+ `getBookingRuleDefaults`; `GET`/`PUT /v1/booking-rules`, `PUT /v1/pools/{pool}/booking-rules`):
163
+ a resource's rules as they apply (`effectiveBookingRules`, `effective_booking_rules`) are the
164
+ tenant's, overlaid by its pool's, overlaid by its own. A new tenant table under forced row-level
165
+ security, `booking_rule_defaults`, which erasure deletes. The dashboard's rules form has both new
166
+ rules and shows each inherited rule and where it comes from; an inherited on-or-off rule
167
+ (`enforceHours`, `requireCertain`) is a choice of default, on or off, so a resource can turn it
168
+ off. At most 1,000 pools a tenant have defaults (`pool_rules_limit_reached`). Merged rules are
169
+ read leniently: an offset under a shorter step applies modulo it, and an invalid field is ignored
170
+ and logged, never read as refusing every booking.
171
+ - Pool availability counts a member busy over its bookings' occupied time, buffers included, not
172
+ the buffers a new booking would keep.
173
+
174
+ ### Upgrading
175
+
176
+ - The migration adds `reservations.buffer_before_ms` and `occupied_starts_at` and
177
+ `calendar_events.buffer_before_ms` without rewriting a row (existing rows keep no leading buffer,
178
+ so their occupied time is unchanged), adds their checks NOT VALID and validates them after it
179
+ commits, and rebuilds `slotlock_reservations_no_overlap` once.
180
+ - The rebuild holds an ACCESS EXCLUSIVE lock on `slotlock.reservations` for the whole index build,
181
+ about 15 seconds per million rows as measured in review: every read and write of reservations
182
+ waits until it ends. Schedule it, and for a very large table raise the migration's
183
+ `statementTimeoutMs` (`SlotlockMigrationOptions`, default 900,000 ms) so the build is not
184
+ cancelled.
185
+ - `slotlock migrate` applies row-level security to the new table and grants it to the serving role.
186
+ A library user runs `applyTenantRls`, then `grantApplicationRole` right after `applySchema` and
187
+ before serving traffic: until then every booking fails with a permission error on
188
+ `booking_rule_defaults`, which every booking reads.
189
+
190
+ ### Changed
191
+
192
+ - Breaking: the feed now also records configuration changes, so a reader or webhook with no
193
+ `event_types` filter receives them too; filter by type to keep only bookings.
194
+ - Breaking: `coverage.stale` names its source by `source_id`, as `subject` and in `data`, in
195
+ place of `source`, and no longer carries `last_error`: a source can be an iCalendar URL with a
196
+ token or an address, and an error can quote it. `slotlock_get_coverage` still shows both to the tenant, with the
197
+ `source_id` to match a change to its source. Changes already on the feed keep the old payload
198
+ until they are pruned.
199
+
6
200
  ## 0.5.2 - 2026-10-28
7
201
 
8
202
  ### Added
package/README.md CHANGED
@@ -5,44 +5,6 @@ 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
-
46
8
  ## Why it is built for agents
47
9
 
48
10
  - Resources own calendars, not users, and the tenant comes only from the authenticated caller.
@@ -155,6 +117,8 @@ slotlock rules clear --resource vehicle-42
155
117
  | `minNoticeMinutes` | starts at least this far ahead |
156
118
  | `maxAdvanceDays` | starts at most this far ahead |
157
119
  | `slotStepMinutes` | starts on a step from local midnight in the resource's time zone |
120
+ | `stepOffsetMinutes` | starts this far past each step (0 to the step less one): with a 30-minute step, 15 starts bookings on :15 and :45 |
121
+ | `bufferBeforeMinutes` | keeps this long free before it: its leading buffer, which the database enforces |
158
122
  | `turnaroundMinutes` | keeps this long free after it: its trailing buffer, which the database enforces |
159
123
  | `enforceHours` | lies inside the resource's bookable hours |
160
124
  | `checkInMinutes` | is claimed (`checkInReservation`) this long after its start, or maintenance releases it |
@@ -169,17 +133,32 @@ slotlock rules clear --resource vehicle-42
169
133
  step and hours. Imported calendar events are not checked: they happened elsewhere.
170
134
  - Searches (`slotlock_find_next_available`, `slotlock_find_available_slots`, `findNextAvailableFor`)
171
135
  offer only slots the rules allow, so an offered slot is one the write accepts.
136
+ - Two bookings stay at least the earlier one's trailing buffer plus the later one's leading buffer
137
+ apart: each occupies from its start less its leading buffer to its end plus its trailing one, and
138
+ the exclusion constraint keeps those apart. Free/busy reports that time as busy.
172
139
  - The REST application API sets them with `PUT /v1/resources/{resource_id}/booking-rules`, in
173
140
  snake_case; the store with `setResourceBookingRules`.
141
+ - Defaults: rules for every resource of the tenant (`PUT /v1/booking-rules`) and for every member
142
+ of a pool (`PUT /v1/pools/{pool}/booking-rules`), read back with `GET /v1/booking-rules`; the
143
+ store's `setBookingRuleDefaults` and `getBookingRuleDefaults`. A resource's rules as they apply
144
+ (`effective_booking_rules`, `effectiveBookingRules`) are the tenant's defaults, overlaid by its
145
+ pool's, overlaid by its own: a rule set at a narrower level wins. `checkInMinutes` is a resource's
146
+ own only. The dashboard's resource page shows which rules a resource inherits, and from where.
147
+ - With no rule set anywhere, nothing changes: every rule is opt-in.
148
+ - A tenant has default rules for at most 1,000 pools (`pool_rules_limit_reached`, 409, for one
149
+ more). Merged rules are read leniently: a pool's `stepOffsetMinutes` under a resource's shorter
150
+ step applies modulo that step, and a field that is invalid on its own is ignored (and logged),
151
+ never read as refusing every booking.
174
152
 
175
153
  ### Without Docker
176
154
 
177
155
  With Node.js 22.12+ and PostgreSQL 16 or newer, run each command as `npx slotlock` after
178
156
  `npm install slotlock postgres`, or from a clone, built once, as `node dist/cli.js` (a clone does not
179
- put `slotlock` on your `PATH`). Use two roles, as Compose does:
180
- the server connects as a role that owns nothing and cannot bypass row-level security, and `migrate`
181
- connects as the database owner and grants that role what it needs. `serve` refuses to start as a
182
- superuser or a `BYPASSRLS` role, which row-level security does not confine.
157
+ put `slotlock` on your `PATH`). Use two roles, as Compose does, so two URLs: `serve` connects as
158
+ `DATABASE_URL`, a role that owns nothing and cannot bypass row-level security, and `migrate`
159
+ connects as `SLOTLOCK_MIGRATE_DATABASE_URL`, the schema owner, and grants that role what it needs.
160
+ `serve` refuses to start as a superuser or a `BYPASSRLS` role, which row-level security does not
161
+ confine, and warns (`serving_as_schema_owner`) when its role owns the schema.
183
162
 
184
163
  ```sh
185
164
  npm ci && npm run build
@@ -212,7 +191,7 @@ slotlock key revoke <id>
212
191
 
213
192
  - A key (`slk_…`) is shown once and stored only as a SHA-256. Send it as `Authorization: Bearer`.
214
193
  - It acts in the tenant it was created for (`SLOTLOCK_TENANT`), so one server serves many tenants.
215
- - `read` covers the five tools that only look; `write` covers create, update and delete; `admin`
194
+ - `read` covers the seven tools that only look; `write` covers create, update and delete; `admin`
216
195
  covers the [application API](#application-api), which no agent needs. Default: read and write.
217
196
  - `rotate` keeps the key's id, scopes and bookings; `revoke` stops it on its next request.
218
197
  - A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can
@@ -580,6 +559,9 @@ export async function bookHandover(store: SlotlockStore, tenantRef: string) {
580
559
  }
581
560
  ```
582
561
 
562
+ Run every store method inside `withTenant`. A call outside it is not refused: it runs with no tenant
563
+ context, so as the application role forced row-level security shows it no rows.
564
+
583
565
  `withTenant` validates the tenant, sets the context transaction-locally on the one connection every
584
566
  callback call uses, supports nested savepoints, and restores the previous context. Do not issue a
585
567
  standalone `set_config(..., true)` through a pool: its transaction ends before the next call.
@@ -632,6 +614,49 @@ Writing rules:
632
614
  reference for a genuinely new event.
633
615
  - `listCalendarEvents` and `listResources` return at most 1,000 rows and page with keyset cursors.
634
616
 
617
+ ### Holds, external bookings and the feed
618
+
619
+ <!-- example: examples/store.ts#holds -->
620
+ ```ts
621
+ export async function holdBookAndFollow(store: SlotlockStore, tenantRef: string, resourceId: string) {
622
+ // Inside withTenant: outside it the application role's row-level security shows no rows.
623
+ const booked = await store.withTenant(tenantRef, async (tenant) => {
624
+ // Hold a window for ten minutes while the customer pays, then turn it into a booking.
625
+ const held = await tenant.acquireHold({
626
+ tenantRef,
627
+ resourceId,
628
+ start: new Date('2027-04-05T09:00:00Z'),
629
+ end: new Date('2027-04-05T10:00:00Z'),
630
+ ttlMs: 10 * 60_000,
631
+ });
632
+ if (!held.ok) throw new Error(`not held: ${held.code}`);
633
+ const confirmed = await tenant.confirmHold(held.hold.id, { tenantRef });
634
+ if (!confirmed.ok) throw new Error(`hold not confirmed: ${confirmed.code}`);
635
+
636
+ // A booking your own system already has, by its reference there: revision 1 creates it.
637
+ const imported = await tenant.createExternalReservation({
638
+ tenantRef,
639
+ externalRef: 'BK-43',
640
+ resourceId,
641
+ start: new Date('2027-04-05T11:00:00Z'),
642
+ end: new Date('2027-04-05T12:00:00Z'),
643
+ bufferAfterMs: 15 * 60_000,
644
+ revision: 1,
645
+ });
646
+ if (!imported.ok) throw new Error(`BK-43 not booked: ${imported.code}`);
647
+ return { booking: confirmed.reservation, imported: imported.reservation };
648
+ });
649
+
650
+ // What changed, oldest first, once committed: so in a withTenant of its own. Poll again with
651
+ // `after: feed.next`.
652
+ const feed = await store.withTenant(tenantRef, (tenant) =>
653
+ tenant.listChanges({ tenantRef, limit: 100 }),
654
+ );
655
+ if (!feed.ok) throw new Error(`feed not read: ${feed.code}`);
656
+ return { ...booked, feed };
657
+ }
658
+ ```
659
+
635
660
  ### Recurring events
636
661
 
637
662
  <!-- example: examples/store.ts#recurring -->
@@ -834,8 +859,10 @@ import {
834
859
  type SlotlockStore,
835
860
  createSlotlockAgentServer,
836
861
  createSlotlockStoreAgentBackend,
862
+ createSlotlockStoreChangeSource,
837
863
  } from 'slotlock';
838
864
  import { createSlotlockNodeServer } from 'slotlock/node-server';
865
+ import type { Sql } from 'postgres';
839
866
 
840
867
  const ALLOWED_OPERATIONS: ReadonlySet<SlotlockAgentOperation> = new Set([
841
868
  'slotlock_list_resources',
@@ -849,6 +876,8 @@ const ALLOWED_OPERATIONS: ReadonlySet<SlotlockAgentOperation> = new Set([
849
876
 
850
877
  export interface CalendarServerConfig {
851
878
  store: SlotlockStore;
879
+ /** The store's client: live updates read the change feed's head with it. */
880
+ sql: Sql;
852
881
  publicBaseUrl: string;
853
882
  port: number;
854
883
  verifyToken(token: string): Promise<SlotlockAgentPrincipal | null>;
@@ -869,7 +898,8 @@ export async function startCalendarServer(config: CalendarServerConfig) {
869
898
  }
870
899
  : {}),
871
900
  resourceWindowDays: 90,
872
- subscriptions: { pollIntervalMs: 5_000 },
901
+ // Live updates follow each tenant's change feed: a booking is announced as it commits.
902
+ subscriptions: { changes: createSlotlockStoreChangeSource(config.store, config.sql) },
873
903
  backend: createSlotlockStoreAgentBackend(config.store, {
874
904
  availabilityRules: async () => [
875
905
  { rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', startMinutes: 9 * 60, durationMinutes: 480 },
@@ -957,7 +987,9 @@ Rentals sell a class, not a plate. Put interchangeable resources in a pool
957
987
  members with `slotlock_list_resources` and `pool`, and book the class with `slotlock_book_pool`,
958
988
  which takes the first member that is free and allows the booking, in one transaction: a class of
959
989
  five sells exactly five for a time, however many race (`pool_full` after that). To see how many are
960
- free, `slotlock_get_pool_availability` splits a window where that number changes; to see which,
990
+ free, `slotlock_get_pool_availability` splits a window where that number changes (counting each
991
+ member busy over its bookings' occupied time, buffers included, but not the buffers a new booking
992
+ would keep, so a member it counts free may still refuse a booking that abuts another); to see which,
961
993
  search the members with `slotlock_find_available_slots` and `require_free: 1`: each slot names the
962
994
  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
963
995
  are no longer advertised.
@@ -971,7 +1003,7 @@ A refusal is a result the model can act on:
971
1003
 
972
1004
  | Code | Meaning |
973
1005
  | --- | --- |
974
- | `invalid_arguments` | Input failed the schema, or a window is inverted or longer than 367 days |
1006
+ | `invalid_arguments` | Input failed the schema, or a window is inverted or longer than 367 days. A schema failure adds `issues`, up to 20 `{"path":[…],"message":"…"}`, each naming a field by its path in the arguments (`["rules",0,"start_minutes"]`; `[]` is the arguments as a whole): in `error` on MCP and A2A, beside `code` in REST's problem details |
975
1007
  | `forbidden` | `authorize` refused the call |
976
1008
  | `rate_limited` | `consumeRateLimit` refused the call |
977
1009
  | `not_found`, `resource_not_found`, `event_not_found` | Not visible to this caller, or no such id: a resource id is the `id` `slotlock_list_resources` returns, not its `external_ref` |
@@ -998,7 +1030,7 @@ on stderr.
998
1030
  | HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit (MCP, REST) |
999
1031
  | MCP | `-32602` unknown tool, `arguments` that are not an object, or altered confirmation; `-32603` server fault; `-32601` unknown method |
1000
1032
  | MCP 2026-07-28 | `-32020` header disagrees with body, `-32021` form elicitation not declared, `-32022` unsupported revision, `-31029` rate limit |
1001
- | 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) |
1033
+ | 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` task subscription or extended card, `-32001` task not found (the server keeps no tasks) |
1002
1034
 
1003
1035
  ### Authentication and OAuth discovery
1004
1036
 
@@ -1049,8 +1081,18 @@ On MCP 2026-07-28 each resource is also an MCP resource, `slotlock://resources/{
1049
1081
  returns busy time and coverage for the next `resourceWindowDays` (30). `subscriptions/listen` streams
1050
1082
  `notifications/resources/updated` when a resource's free/busy changes.
1051
1083
 
1052
- - Changes are found by re-reading through your backend every `subscriptions.pollIntervalMs` (10 s),
1053
- re-checking the caller each time.
1084
+ - With `subscriptions.changes` (`createSlotlockStoreChangeSource(store, sql)`; `slotlock serve`
1085
+ sets it), changes come from the tenant's change feed: one reader per tenant per process, shared by
1086
+ all its subscriptions, polls the feed by cursor, one indexed query per read, under the tenant's
1087
+ row-level security. It reads again 250 ms after a read that found changes
1088
+ (`SLOTLOCK_FEED_BUSY_MS`, also the least gap between two reads) and backs off to once a second
1089
+ when quiet (`SLOTLOCK_FEED_IDLE_MS`), so a booking is announced within about a second. A change
1090
+ re-reads the subscriptions watching its resource, at most once a second each however many
1091
+ changes arrive, and each announces only what actually changed in its window. A cursor the feed
1092
+ has pruned past announces every watched resource once, then follows from now.
1093
+ - Each subscription is also re-read every `pollIntervalMs` (15 s with a feed, 10 s without one,
1094
+ which is then the only way changes are found), re-checking the caller each time, so a revoked key
1095
+ or what the feed does not carry (bookable hours) is caught.
1054
1096
  - A subscription ends after `maxDurationMs` (15 minutes), on `shutdown()`, or when access is lost.
1055
1097
  - At most 4 open per caller and 256 per server (`maxPerPrincipal`, `maxTotal`). `maxPerTenant`
1056
1098
  caps one tenant's across all its callers (default `maxTotal`; `slotlock serve` sets 16), so a
@@ -1153,8 +1195,8 @@ waits for a confirmation.
1153
1195
  | `POST /v1/resources/{resource_id}/archive` | Take it out of use; `…/restore` puts it back |
1154
1196
  | `DELETE /v1/resources/{resource_id}` | Delete it with its history (`409 resource_in_use` while anything on it is live) |
1155
1197
  | `GET /v1/resources/{resource_id}/busy?start=…&end=…` | Its occupied time, private resources included |
1156
- | `PUT /v1/resources/{resource_id}/availability` | Its own weekly hours; `[]` never bookable, `null` the server's |
1157
- | `PUT /v1/availability` | Every resource's weekly hours at once |
1198
+ | `PUT /v1/resources/{resource_id}/availability` | `{"rules":[…]}`: its own weekly hours; `"rules":[]` never bookable, `"rules":null` the server's |
1199
+ | `PUT /v1/availability` | `{"rules":[…]}`: every resource's weekly hours at once |
1158
1200
  | `POST /v1/holds` | Hold a window for `ttl_seconds` (up to 7 days) |
1159
1201
  | `GET /v1/holds/{hold_id}` | One live hold; `GET /v1/resources/{resource_id}/holds` lists them |
1160
1202
  | `POST /v1/holds/{hold_id}/confirm` | Turn the hold into a reservation |
@@ -1208,12 +1250,40 @@ curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
1208
1250
 
1209
1251
  ### Changes and webhooks
1210
1252
 
1211
- Every committed change to a tenant's holds, bookings and calendar events is recorded in the same
1212
- transaction (by a database trigger, so no write path skips it), and nothing is recorded that did
1213
- not commit. A change carries ids, times and states, never titles, descriptions or attendees.
1253
+ Every committed change to a tenant's holds, bookings, calendar events and configuration is recorded
1254
+ in the same transaction (by a database trigger, so no write path skips it), and nothing is recorded
1255
+ that did not commit. A change carries ids, times and states, never titles, descriptions, attendees,
1256
+ webhook URLs or secrets.
1257
+
1258
+ - The types: `hold.created`, `.updated`, `.confirmed`, `.released`, `.expired`;
1259
+ `reservation.created`, `.updated`, `.cancelled`, `.checked_in`, `.no_show`; `event.created`,
1260
+ `.updated`, `.cancelled`, `.timezone_shifted`, `.timezone_conflict`; `waitlist.offered`,
1261
+ `.expired`; `coverage.stale`; and configuration: `resource.created`, `resource.updated` (booking
1262
+ and check-in rules, pool, bookable hours, time zone or reference, with `before` and `after` of
1263
+ only the fields that changed), `resource.archived`, `.restored`, `.deleted`; `coverage.recorded`
1264
+ (a source's first read; reading it again is not a change) and `coverage.deleted`. A `coverage.*`
1265
+ change names its source by `source_id` (also its `subject`), never the source itself, which can
1266
+ be an iCalendar URL with a token or an address: `src_` and 32 random hex digits, made when the
1267
+ source is first recorded for the resource and kept until it is deleted, so it cannot be worked
1268
+ out from the source. `slotlock_get_coverage` (`listCoverage`) gives it beside the source, to
1269
+ match a change to its source. A change about a source deleted before 0.6 named it is
1270
+ `src_redacted`;
1271
+ `webhook.created`, `.enabled`, `.disabled`, `.deleted` (the id and `event_types`, never the URL);
1272
+ `key.created`, `.rotated`, `.revoked` (the id, scopes and expiry, never the key, its digest,
1273
+ prefix or name). `SLOTLOCK_CHANGE_TYPES` lists them.
1274
+ - Each change records who made it (`actor`: the API key or agent), the API key's id (`key_id`) when
1275
+ a key made it, and where it came from (`origin`): `mcp`, `a2a`, `rest` (the REST agent routes),
1276
+ `application` (the application API), `dashboard`, `dashboard-assistant`, `maintenance`, or
1277
+ `library`: code calling the store itself, inside `withTenant` with no `origin` or outside it, and
1278
+ any write that names none. Never an address or anything else about a person. A change an earlier
1279
+ release recorded has neither (`null`).
1280
+ - `key_id` is pseudonymous: the key's id, never the key or its name. It stays on the changes the key
1281
+ made after the key is revoked or deleted, for as long as the feed keeps them (the retention below,
1282
+ and longer while an enabled webhook has still to receive them); erasing the tenant removes them.
1214
1283
 
1215
1284
  - `GET /v1/changes?cursor=…` lists them oldest first as [CloudEvents 1.0](https://cloudevents.io)
1216
- (`io.pylota.slotlock.hold.created`, `.reservation.cancelled`, `.event.updated`, …). Follow
1285
+ (`io.pylota.slotlock.hold.created`, `.reservation.cancelled`, `.event.updated`, …), with the
1286
+ extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them. Follow
1217
1287
  `next_cursor`, which comes back even when nothing is new: it never skips a change, even one whose
1218
1288
  transaction commits after a later one's. Changes are kept 30 days (`SLOTLOCK_RETENTION_DAYS`,
1219
1289
  `retentionDays` in `createSlotlockStore`, 1-3,650, for `serve` and `maintain` alike); a cursor
@@ -1224,7 +1294,7 @@ not commit. A change carries ids, times and states, never titles, descriptions o
1224
1294
  - `POST /v1/webhooks` with an https `url` (and `event_types`, optionally) sends each change from
1225
1295
  then on as a [Standard Webhooks](https://www.standardwebhooks.com) request: `webhook-id` (the
1226
1296
  change's id, the same on every retry), `webhook-timestamp` and `webhook-signature`, keyed by the
1227
- `whsec_` secret the call returns once. Verify it with any Standard Webhooks library, and use
1297
+ `whsec_` secret the call returns once. `event_types` takes any of the types above. Verify it with any Standard Webhooks library, and use
1228
1298
  `webhook-id` to drop a repeat: delivery is at least once.
1229
1299
  - Changes go to each webhook in order, one at a time, until one fails; that one is retried after
1230
1300
  5 seconds, 5 minutes, 30 minutes, 2, 5, then every 10 hours. After a day of failures, or a `410`,
@@ -1234,11 +1304,14 @@ not commit. A change carries ids, times and states, never titles, descriptions o
1234
1304
  an enabled webhook has still to receive, however old. At most 10 webhooks per tenant.
1235
1305
  - Each request has 15 seconds in all, and a tenant's pass 30 seconds; tenants are delivered 8 at
1236
1306
  a time, so a slow endpoint holds up only its own tenant.
1237
- - History reads the same record backwards, with who made each change (the API key or agent) and,
1238
- for a change of an existing booking or event, what it was before:
1239
- `GET /v1/resources/{id}/history`, `GET /v1/reservations/{reference}/history`,
1240
- `GET /v1/tenant/events/{id}/history`, newest first, paged by `before`. In the library, pass
1241
- `actor` to `withTenant` and read `listHistory`. It is kept as long as the feed and erased with
1307
+ - History reads the same record backwards, with who made each change (`actor`, `key_id`), where
1308
+ from (`origin`) and, for a change of an existing booking, event or resource, what it was before:
1309
+ `GET /v1/resources/{id}/history` (its bookings, events and its own configuration),
1310
+ `GET /v1/reservations/{reference}/history`, `GET /v1/tenant/events/{id}/history`, newest first,
1311
+ paged by `before`. In the library, pass `actor`, `origin` and `keyId` to `withTenant` (`origin`
1312
+ defaults to the enclosing call's, else `library`; `createSlotlockApiKeyAuthenticator` gives the
1313
+ principal its `keyId`), and `actor` and `origin` to the API key store's `create`, `rotate` and
1314
+ `revoke`; read `listHistory`. It is kept as long as the feed and erased with
1242
1315
  the tenant.
1243
1316
  - When time zone rules change (a new tzdata), a booking's instant and the wall time it was made for
1244
1317
  part: Winnipeg's 09:00 is no longer the instant stored. Slotlock keeps both, and maintenance finds
@@ -1247,7 +1320,8 @@ not commit. A change carries ids, times and states, never titles, descriptions o
1247
1320
  to `maintainSlotlockTenant` moves them instead, as a new revision, unless that overlaps another
1248
1321
  booking (`event.timezone_conflict`; it stays, for a person or an agent to decide). `/healthz`
1249
1322
  reports the `tzdata` the server runs.
1250
- - Feed health: `GET /v1/resources/{id}/coverage` shows each source's window, when it was last read,
1323
+ - Feed health: `GET /v1/resources/{id}/coverage` shows each source (and its `source_id`, as the feed
1324
+ names it), its window, when it was last read,
1251
1325
  its last error (`POST /v1/resources/{id}/coverage/{source}/failure` records one) and whether it is
1252
1326
  stale (not read for a day, so the time it covers is unproven, never free). A source that goes stale
1253
1327
  is put on the feed once, as `coverage.stale`, until it is read again. Retire a sync you have
@@ -1421,6 +1495,15 @@ export async function listResourcesOverA2a(baseUrl: string, token: string) {
1421
1495
  Send one `application/json` data part, `{"skill": "<id>", "arguments": {…}}`, with header
1422
1496
  `A2A-Version: 1.0`. Each skill in the agent card has an example; replies are synchronous.
1423
1497
 
1498
+ The card declares `streaming`. `SendStreamingMessage` with any skill streams its reply as one
1499
+ message. With `slotlock_watch_resources` (`{"resource_ids": [...]}`, up to 20) it streams a watch:
1500
+ a task in `TASK_STATE_WORKING` naming the resources it can see, then a status update
1501
+ `{"resource_id", "uri"}` each time one's free/busy changes, from the same feed and under the same
1502
+ caps as MCP subscriptions, until `TASK_STATE_COMPLETED` (its duration, shutdown) or
1503
+ `TASK_STATE_FAILED` (access withdrawn). Closing the stream ends it; the task is not kept, so
1504
+ `SubscribeToTask` and `GetTask` do not find it. Push notifications are not offered: a tenant's
1505
+ changes reach a URL as signed webhooks.
1506
+
1424
1507
  ### MCP Apps
1425
1508
 
1426
1509
  Read-only tools link to `ui://slotlock/calendar`, a self-contained calendar view that hosts
@@ -1506,7 +1589,9 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1506
1589
  7. Monitor conflicts, refused rules, sync lag, capped maintenance runs, quota errors and confirmation outcomes.
1507
1590
  8. Keep writes confirmation-gated wherever your policy needs a person (`slotlock serve`:
1508
1591
  `SLOTLOCK_CONFIRM_WRITES`, `none` by default).
1509
- 9. Size live updates: at most `maxTotal` backend reads per `pollIntervalMs`.
1592
+ 9. Size live updates: one indexed feed query per second per tenant with an open subscription (four
1593
+ a second while its feed is busy), at most one backend read a second per subscription a change
1594
+ touches, and at most `maxTotal` backend reads per `pollIntervalMs`.
1510
1595
  10. Give each agent its own API key with the narrowest scope and an expiry.
1511
1596
  11. After each deployment, check it end to end with a short-lived admin key, then revoke the key:
1512
1597
  `SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs`, from a clone
@@ -1517,7 +1602,13 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1517
1602
  server (`SLOTLOCK_LIMITS`); behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` and firewall
1518
1603
  the server so nothing reaches it except through that proxy. Embedding the
1519
1604
  agent server, give it a `consumeRateLimit`. Watch serve's `refusals` log line for floods.
1520
- 13. Bound how long a transaction may run. A store over your own transaction (a savepoint per call)
1605
+ 13. On an upgrade, migrate first, then, as a library user, run `grantApplicationRole` again right
1606
+ after `applySchema` and before serving traffic: a release that adds a table (the leading-buffer
1607
+ release adds `booking_rule_defaults`, which every booking reads) fails every booking with a
1608
+ permission error until the serving role is granted it. `slotlock migrate` does both. A migration
1609
+ that rebuilds a constraint holds its table's lock for the whole build (see the CHANGELOG's
1610
+ Upgrading notes).
1611
+ 14. Bound how long a transaction may run. A store over your own transaction (a savepoint per call)
1521
1612
  keeps its locks and its snapshot for as long as yours runs, and a hold you confirm inside it is
1522
1613
  judged by the statement's clock, not the transaction's. On PostgreSQL 17 and later, give the
1523
1614
  serving role a `transaction_timeout` above your longest legitimate transaction
@@ -1536,13 +1627,15 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1536
1627
  | PostgreSQL | 16, 17 and 18 (CI runs the database suites on each; Compose ships 18) |
1537
1628
  | postgres (peer) | ^3.4.5 |
1538
1629
 
1630
+ 0.2.0 and older can double-book around daylight-saving changes: [known issues](https://github.com/PILOTAAI/slotlock/blob/main/docs/release/0.2.0-known-issues.md#the-known-issues).
1631
+
1539
1632
  ## Project status and boundaries
1540
1633
 
1541
1634
  Slotlock is availability maths, holds and reservations, events with recurrence, coverage-aware
1542
1635
  free/busy, tenant isolation, iCalendar interchange, MCP and A2A with human confirmation and live
1543
1636
  updates, an MCP Apps view, REST with the application API, scoped API keys, a GitHub sign-in
1544
- dashboard, maintenance, and clients for five languages. [Hosted Slotlock](docs/hosted/README.md),
1545
- not open yet, will run it for you. Provider sync (Google, Microsoft, CalDAV), notifications to people, and a human
1637
+ dashboard, maintenance, and clients for five languages. [Hosted Slotlock](docs/hosted/README.md)
1638
+ is not open; self-hosting is the only supported path today. Provider sync (Google, Microsoft, CalDAV), notifications to people, and a human
1546
1639
  calendar app are yours to build around it. What each version promises is in
1547
1640
  [VERSIONING.md](VERSIONING.md).
1548
1641