slotlock 0.5.2 → 0.5.3

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,104 @@
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.5.3 - 2026-10-28
7
+
8
+ ### Added
9
+
10
+ - Configuration changes on the feed and in history (RFC 0002 P1-5, P1-6), each in the change's own
11
+ transaction, by trigger: `resource.created`, `resource.updated` (booking and check-in rules,
12
+ pool, bookable hours, time zone or reference, with `before` and `after` of the fields that
13
+ changed), `resource.archived`, `resource.restored`, `resource.deleted`, `coverage.recorded` (a
14
+ source's first read) and `coverage.deleted`, `webhook.created`, `webhook.enabled` and
15
+ `webhook.deleted`, and `key.created`, `key.rotated` and `key.revoked`. Writing a value that is
16
+ already there records nothing. Rows carry ids and states: never a webhook's URL or secret, nor an
17
+ API key, its digest, prefix or name, nor a coverage source, which `coverage.*` changes name by
18
+ `source_id` (`src_` and 24 hex digits, a digest of the tenant and the source; `subject` too).
19
+ `slotlock_get_coverage` and `listCoverage` give each source's `source_id`, and
20
+ `slotlockCoverageSourceId` works it out. A webhook's `event_types` takes any of them, up to every
21
+ type (it took at most 20).
22
+ - Where a change came from: `withTenant` takes `origin` (`mcp`, `a2a`, `rest`, `application`,
23
+ `dashboard`, `dashboard-assistant`, `maintenance` or `library`, the default, also for a write
24
+ outside `withTenant`) and `keyId`, set by
25
+ each surface: the agent server per protocol, the application API, the dashboard and its
26
+ assistant, maintenance and webhook delivery. API key principals carry `keyId`, and the API key
27
+ store's `create`, `rotate` and `revoke` take `actor` and `origin`. The feed's new nullable
28
+ columns `origin` and `key_id` are in `listHistory` and `listChanges` (`origin`, `keyId`), the
29
+ history API (`origin`, `key_id`) and CloudEvents (extension attributes `slotlockorigin` and
30
+ `slotlockkeyid`). Rows written before keep null; an origin this release does not know reads as
31
+ null. `key_id` is pseudonymous and stays on a revoked key's changes for the retention. No address
32
+ or other personal data is recorded. The column's CHECK is added NOT VALID and validated after the
33
+ migration commits, so upgrading scans no rows under the table lock. An authenticator's key id
34
+ that is not a version 1-5 UUID is left out, as documented, rather than refusing the call.
35
+ - Live updates from the change feed (RFC 0002 P1-5). With `subscriptions.changes`
36
+ (`createSlotlockStoreChangeSource(store, sql)`, which `slotlock serve` sets), MCP resource
37
+ subscriptions follow each tenant's change feed instead of re-reading every 10 seconds: one reader
38
+ per tenant per process, shared by its subscriptions, polls the feed by cursor (one indexed query,
39
+ every 250 ms while changes come, `SLOTLOCK_FEED_BUSY_MS`, backing off to 1 s when quiet,
40
+ `SLOTLOCK_FEED_IDLE_MS`) and re-reads only the subscriptions a change touches, each at most once a
41
+ second, so a booking is announced within about a second. Nothing is added to the write path: no
42
+ trigger, no `NOTIFY`. A cursor pruned past announces every watched resource once. The re-read every
43
+ `pollIntervalMs` stays as the fallback and revalidates the caller (15 s with a feed), and the
44
+ subscription caps are unchanged. `slotlockChangeFeedHead` is where a reader starts.
45
+ - A2A streaming: the card declares `streaming`. `SendStreamingMessage` streams any skill's reply as
46
+ one message, and the `slotlock_watch_resources` skill streams a watch task with a status update
47
+ for each change to a watched resource's free/busy, from the same feed and caps as MCP
48
+ subscriptions, ending `TASK_STATE_COMPLETED` or, when access is withdrawn, `TASK_STATE_FAILED`.
49
+ Push notifications stay unsupported: webhooks deliver a tenant's changes to a URL.
50
+ - Benchmarks on every release (RFC 0002, P2-1): once a tag's suite passes, release.yml runs
51
+ `scripts/bench.mjs` against a PostgreSQL 16 service container, attaches `bench.json` and
52
+ `bench.md` to the GitHub release and rebuilds the site, whose new Benchmarks page shows the newest
53
+ results with the method and caveats. The job is informational: nothing waits on it and its failure
54
+ fails no release. The script now also measures free/busy reads and the non-overlapping workload
55
+ with durable commits off, records the PostgreSQL version, CPU count, clients and duration, and
56
+ writes both files with `--out DIR`.
57
+ - Booking rules: a leading buffer (`bufferBeforeMinutes`), the time kept free before each booking,
58
+ so two bookings stay the earlier one's trailing buffer plus the later one's leading buffer apart.
59
+ A reservation now occupies from `occupied_starts_at` (its start less `buffer_before_ms`, set by
60
+ a trigger) and the `slotlock_reservations_no_overlap` constraint keeps that time apart; free/busy,
61
+ searches, pool availability, holds and their reaping, coverage checks, series rolls and the
62
+ waitlist all read it.
63
+ - Booking rules: a step offset (`stepOffsetMinutes`, 0 to the step less one): with a 30-minute step
64
+ and 15, bookings start on :15 and :45, local time in the resource's zone, on writes and searches.
65
+ - Default booking rules for the tenant and for each pool (`setBookingRuleDefaults`,
66
+ `getBookingRuleDefaults`; `GET`/`PUT /v1/booking-rules`, `PUT /v1/pools/{pool}/booking-rules`):
67
+ a resource's rules as they apply (`effectiveBookingRules`, `effective_booking_rules`) are the
68
+ tenant's, overlaid by its pool's, overlaid by its own. A new tenant table under forced row-level
69
+ security, `booking_rule_defaults`, which erasure deletes. The dashboard's rules form has both new
70
+ rules and shows each inherited rule and where it comes from; an inherited on-or-off rule
71
+ (`enforceHours`, `requireCertain`) is a choice of default, on or off, so a resource can turn it
72
+ off. At most 1,000 pools a tenant have defaults (`pool_rules_limit_reached`). Merged rules are
73
+ read leniently: an offset under a shorter step applies modulo it, and an invalid field is ignored
74
+ and logged, never read as refusing every booking.
75
+ - Pool availability counts a member busy over its bookings' occupied time, buffers included, not
76
+ the buffers a new booking would keep.
77
+
78
+ ### Upgrading
79
+
80
+ - The migration adds `reservations.buffer_before_ms` and `occupied_starts_at` and
81
+ `calendar_events.buffer_before_ms` without rewriting a row (existing rows keep no leading buffer,
82
+ so their occupied time is unchanged), adds their checks NOT VALID and validates them after it
83
+ commits, and rebuilds `slotlock_reservations_no_overlap` once.
84
+ - The rebuild holds an ACCESS EXCLUSIVE lock on `slotlock.reservations` for the whole index build,
85
+ about 15 seconds per million rows as measured in review: every read and write of reservations
86
+ waits until it ends. Schedule it, and for a very large table raise the migration's
87
+ `statementTimeoutMs` (`SlotlockMigrationOptions`, default 900,000 ms) so the build is not
88
+ cancelled.
89
+ - `slotlock migrate` applies row-level security to the new table and grants it to the serving role.
90
+ A library user runs `applyTenantRls`, then `grantApplicationRole` right after `applySchema` and
91
+ before serving traffic: until then every booking fails with a permission error on
92
+ `booking_rule_defaults`, which every booking reads.
93
+
94
+ ### Changed
95
+
96
+ - The feed now also records configuration changes, so a reader or webhook with no `event_types`
97
+ filter receives them too; filter by type to keep only bookings.
98
+ - `coverage.stale` names its source by `source_id`, as `subject` and in `data`, in place of `source`,
99
+ and no longer carries `last_error`: a source can be an iCalendar URL with a token or an address,
100
+ and an error can quote it. `slotlock_get_coverage` still shows both to the tenant, with the
101
+ `source_id` to match a change to its source. Changes already on the feed keep the old payload
102
+ until they are pruned.
103
+
6
104
  ## 0.5.2 - 2026-10-28
7
105
 
8
106
  ### Added
package/README.md CHANGED
@@ -155,6 +155,8 @@ slotlock rules clear --resource vehicle-42
155
155
  | `minNoticeMinutes` | starts at least this far ahead |
156
156
  | `maxAdvanceDays` | starts at most this far ahead |
157
157
  | `slotStepMinutes` | starts on a step from local midnight in the resource's time zone |
158
+ | `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 |
159
+ | `bufferBeforeMinutes` | keeps this long free before it: its leading buffer, which the database enforces |
158
160
  | `turnaroundMinutes` | keeps this long free after it: its trailing buffer, which the database enforces |
159
161
  | `enforceHours` | lies inside the resource's bookable hours |
160
162
  | `checkInMinutes` | is claimed (`checkInReservation`) this long after its start, or maintenance releases it |
@@ -169,8 +171,22 @@ slotlock rules clear --resource vehicle-42
169
171
  step and hours. Imported calendar events are not checked: they happened elsewhere.
170
172
  - Searches (`slotlock_find_next_available`, `slotlock_find_available_slots`, `findNextAvailableFor`)
171
173
  offer only slots the rules allow, so an offered slot is one the write accepts.
174
+ - Two bookings stay at least the earlier one's trailing buffer plus the later one's leading buffer
175
+ apart: each occupies from its start less its leading buffer to its end plus its trailing one, and
176
+ the exclusion constraint keeps those apart. Free/busy reports that time as busy.
172
177
  - The REST application API sets them with `PUT /v1/resources/{resource_id}/booking-rules`, in
173
178
  snake_case; the store with `setResourceBookingRules`.
179
+ - Defaults: rules for every resource of the tenant (`PUT /v1/booking-rules`) and for every member
180
+ of a pool (`PUT /v1/pools/{pool}/booking-rules`), read back with `GET /v1/booking-rules`; the
181
+ store's `setBookingRuleDefaults` and `getBookingRuleDefaults`. A resource's rules as they apply
182
+ (`effective_booking_rules`, `effectiveBookingRules`) are the tenant's defaults, overlaid by its
183
+ pool's, overlaid by its own: a rule set at a narrower level wins. `checkInMinutes` is a resource's
184
+ own only. The dashboard's resource page shows which rules a resource inherits, and from where.
185
+ - With no rule set anywhere, nothing changes: every rule is opt-in.
186
+ - A tenant has default rules for at most 1,000 pools (`pool_rules_limit_reached`, 409, for one
187
+ more). Merged rules are read leniently: a pool's `stepOffsetMinutes` under a resource's shorter
188
+ step applies modulo that step, and a field that is invalid on its own is ignored (and logged),
189
+ never read as refusing every booking.
174
190
 
175
191
  ### Without Docker
176
192
 
@@ -834,8 +850,10 @@ import {
834
850
  type SlotlockStore,
835
851
  createSlotlockAgentServer,
836
852
  createSlotlockStoreAgentBackend,
853
+ createSlotlockStoreChangeSource,
837
854
  } from 'slotlock';
838
855
  import { createSlotlockNodeServer } from 'slotlock/node-server';
856
+ import type { Sql } from 'postgres';
839
857
 
840
858
  const ALLOWED_OPERATIONS: ReadonlySet<SlotlockAgentOperation> = new Set([
841
859
  'slotlock_list_resources',
@@ -849,6 +867,8 @@ const ALLOWED_OPERATIONS: ReadonlySet<SlotlockAgentOperation> = new Set([
849
867
 
850
868
  export interface CalendarServerConfig {
851
869
  store: SlotlockStore;
870
+ /** The store's client: live updates read the change feed's head with it. */
871
+ sql: Sql;
852
872
  publicBaseUrl: string;
853
873
  port: number;
854
874
  verifyToken(token: string): Promise<SlotlockAgentPrincipal | null>;
@@ -869,7 +889,8 @@ export async function startCalendarServer(config: CalendarServerConfig) {
869
889
  }
870
890
  : {}),
871
891
  resourceWindowDays: 90,
872
- subscriptions: { pollIntervalMs: 5_000 },
892
+ // Live updates follow each tenant's change feed: a booking is announced as it commits.
893
+ subscriptions: { changes: createSlotlockStoreChangeSource(config.store, config.sql) },
873
894
  backend: createSlotlockStoreAgentBackend(config.store, {
874
895
  availabilityRules: async () => [
875
896
  { rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', startMinutes: 9 * 60, durationMinutes: 480 },
@@ -957,7 +978,9 @@ Rentals sell a class, not a plate. Put interchangeable resources in a pool
957
978
  members with `slotlock_list_resources` and `pool`, and book the class with `slotlock_book_pool`,
958
979
  which takes the first member that is free and allows the booking, in one transaction: a class of
959
980
  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,
981
+ free, `slotlock_get_pool_availability` splits a window where that number changes (counting each
982
+ member busy over its bookings' occupied time, buffers included, but not the buffers a new booking
983
+ would keep, so a member it counts free may still refuse a booking that abuts another); to see which,
961
984
  search the members with `slotlock_find_available_slots` and `require_free: 1`: each slot names the
962
985
  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
986
  are no longer advertised.
@@ -998,7 +1021,7 @@ on stderr.
998
1021
  | HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit (MCP, REST) |
999
1022
  | MCP | `-32602` unknown tool, `arguments` that are not an object, or altered confirmation; `-32603` server fault; `-32601` unknown method |
1000
1023
  | 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) |
1024
+ | 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
1025
 
1003
1026
  ### Authentication and OAuth discovery
1004
1027
 
@@ -1049,8 +1072,18 @@ On MCP 2026-07-28 each resource is also an MCP resource, `slotlock://resources/{
1049
1072
  returns busy time and coverage for the next `resourceWindowDays` (30). `subscriptions/listen` streams
1050
1073
  `notifications/resources/updated` when a resource's free/busy changes.
1051
1074
 
1052
- - Changes are found by re-reading through your backend every `subscriptions.pollIntervalMs` (10 s),
1053
- re-checking the caller each time.
1075
+ - With `subscriptions.changes` (`createSlotlockStoreChangeSource(store, sql)`; `slotlock serve`
1076
+ sets it), changes come from the tenant's change feed: one reader per tenant per process, shared by
1077
+ all its subscriptions, polls the feed by cursor, one indexed query per read, under the tenant's
1078
+ row-level security. It reads again 250 ms after a read that found changes
1079
+ (`SLOTLOCK_FEED_BUSY_MS`, also the least gap between two reads) and backs off to once a second
1080
+ when quiet (`SLOTLOCK_FEED_IDLE_MS`), so a booking is announced within about a second. A change
1081
+ re-reads the subscriptions watching its resource, at most once a second each however many
1082
+ changes arrive, and each announces only what actually changed in its window. A cursor the feed
1083
+ has pruned past announces every watched resource once, then follows from now.
1084
+ - Each subscription is also re-read every `pollIntervalMs` (15 s with a feed, 10 s without one,
1085
+ which is then the only way changes are found), re-checking the caller each time, so a revoked key
1086
+ or what the feed does not carry (bookable hours) is caught.
1054
1087
  - A subscription ends after `maxDurationMs` (15 minutes), on `shutdown()`, or when access is lost.
1055
1088
  - At most 4 open per caller and 256 per server (`maxPerPrincipal`, `maxTotal`). `maxPerTenant`
1056
1089
  caps one tenant's across all its callers (default `maxTotal`; `slotlock serve` sets 16), so a
@@ -1208,12 +1241,37 @@ curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
1208
1241
 
1209
1242
  ### Changes and webhooks
1210
1243
 
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.
1244
+ Every committed change to a tenant's holds, bookings, calendar events and configuration is recorded
1245
+ in the same transaction (by a database trigger, so no write path skips it), and nothing is recorded
1246
+ that did not commit. A change carries ids, times and states, never titles, descriptions, attendees,
1247
+ webhook URLs or secrets.
1248
+
1249
+ - The types: `hold.created`, `.updated`, `.confirmed`, `.released`, `.expired`;
1250
+ `reservation.created`, `.updated`, `.cancelled`, `.checked_in`, `.no_show`; `event.created`,
1251
+ `.updated`, `.cancelled`, `.timezone_shifted`, `.timezone_conflict`; `waitlist.offered`,
1252
+ `.expired`; `coverage.stale`; and configuration: `resource.created`, `resource.updated` (booking
1253
+ and check-in rules, pool, bookable hours, time zone or reference, with `before` and `after` of
1254
+ only the fields that changed), `resource.archived`, `.restored`, `.deleted`; `coverage.recorded`
1255
+ (a source's first read; reading it again is not a change) and `coverage.deleted`. A `coverage.*`
1256
+ change names its source by `source_id` (also its `subject`), a digest of the tenant and the source
1257
+ that `slotlock_get_coverage` and `slotlockCoverageSourceId` give too, never the source itself,
1258
+ which can be an iCalendar URL with a token or an address;
1259
+ `webhook.created`, `.enabled`, `.disabled`, `.deleted` (the id and `event_types`, never the URL);
1260
+ `key.created`, `.rotated`, `.revoked` (the id, scopes and expiry, never the key, its digest,
1261
+ prefix or name). `SLOTLOCK_CHANGE_TYPES` lists them.
1262
+ - Each change records who made it (`actor`: the API key or agent), the API key's id (`key_id`) when
1263
+ a key made it, and where it came from (`origin`): `mcp`, `a2a`, `rest` (the REST agent routes),
1264
+ `application` (the application API), `dashboard`, `dashboard-assistant`, `maintenance`, or
1265
+ `library`: code calling the store itself, inside `withTenant` with no `origin` or outside it, and
1266
+ any write that names none. Never an address or anything else about a person. A change an earlier
1267
+ release recorded has neither (`null`).
1268
+ - `key_id` is pseudonymous: the key's id, never the key or its name. It stays on the changes the key
1269
+ made after the key is revoked or deleted, for as long as the feed keeps them (the retention below,
1270
+ and longer while an enabled webhook has still to receive them); erasing the tenant removes them.
1214
1271
 
1215
1272
  - `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
1273
+ (`io.pylota.slotlock.hold.created`, `.reservation.cancelled`, `.event.updated`, …), with the
1274
+ extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them. Follow
1217
1275
  `next_cursor`, which comes back even when nothing is new: it never skips a change, even one whose
1218
1276
  transaction commits after a later one's. Changes are kept 30 days (`SLOTLOCK_RETENTION_DAYS`,
1219
1277
  `retentionDays` in `createSlotlockStore`, 1-3,650, for `serve` and `maintain` alike); a cursor
@@ -1224,7 +1282,7 @@ not commit. A change carries ids, times and states, never titles, descriptions o
1224
1282
  - `POST /v1/webhooks` with an https `url` (and `event_types`, optionally) sends each change from
1225
1283
  then on as a [Standard Webhooks](https://www.standardwebhooks.com) request: `webhook-id` (the
1226
1284
  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
1285
+ `whsec_` secret the call returns once. `event_types` takes any of the types above. Verify it with any Standard Webhooks library, and use
1228
1286
  `webhook-id` to drop a repeat: delivery is at least once.
1229
1287
  - Changes go to each webhook in order, one at a time, until one fails; that one is retried after
1230
1288
  5 seconds, 5 minutes, 30 minutes, 2, 5, then every 10 hours. After a day of failures, or a `410`,
@@ -1234,11 +1292,14 @@ not commit. A change carries ids, times and states, never titles, descriptions o
1234
1292
  an enabled webhook has still to receive, however old. At most 10 webhooks per tenant.
1235
1293
  - Each request has 15 seconds in all, and a tenant's pass 30 seconds; tenants are delivered 8 at
1236
1294
  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
1295
+ - History reads the same record backwards, with who made each change (`actor`, `key_id`), where
1296
+ from (`origin`) and, for a change of an existing booking, event or resource, what it was before:
1297
+ `GET /v1/resources/{id}/history` (its bookings, events and its own configuration),
1298
+ `GET /v1/reservations/{reference}/history`, `GET /v1/tenant/events/{id}/history`, newest first,
1299
+ paged by `before`. In the library, pass `actor`, `origin` and `keyId` to `withTenant` (`origin`
1300
+ defaults to the enclosing call's, else `library`; `createSlotlockApiKeyAuthenticator` gives the
1301
+ principal its `keyId`), and `actor` and `origin` to the API key store's `create`, `rotate` and
1302
+ `revoke`; read `listHistory`. It is kept as long as the feed and erased with
1242
1303
  the tenant.
1243
1304
  - When time zone rules change (a new tzdata), a booking's instant and the wall time it was made for
1244
1305
  part: Winnipeg's 09:00 is no longer the instant stored. Slotlock keeps both, and maintenance finds
@@ -1247,7 +1308,8 @@ not commit. A change carries ids, times and states, never titles, descriptions o
1247
1308
  to `maintainSlotlockTenant` moves them instead, as a new revision, unless that overlaps another
1248
1309
  booking (`event.timezone_conflict`; it stays, for a person or an agent to decide). `/healthz`
1249
1310
  reports the `tzdata` the server runs.
1250
- - Feed health: `GET /v1/resources/{id}/coverage` shows each source's window, when it was last read,
1311
+ - Feed health: `GET /v1/resources/{id}/coverage` shows each source (and its `source_id`, as the feed
1312
+ names it), its window, when it was last read,
1251
1313
  its last error (`POST /v1/resources/{id}/coverage/{source}/failure` records one) and whether it is
1252
1314
  stale (not read for a day, so the time it covers is unproven, never free). A source that goes stale
1253
1315
  is put on the feed once, as `coverage.stale`, until it is read again. Retire a sync you have
@@ -1421,6 +1483,15 @@ export async function listResourcesOverA2a(baseUrl: string, token: string) {
1421
1483
  Send one `application/json` data part, `{"skill": "<id>", "arguments": {…}}`, with header
1422
1484
  `A2A-Version: 1.0`. Each skill in the agent card has an example; replies are synchronous.
1423
1485
 
1486
+ The card declares `streaming`. `SendStreamingMessage` with any skill streams its reply as one
1487
+ message. With `slotlock_watch_resources` (`{"resource_ids": [...]}`, up to 20) it streams a watch:
1488
+ a task in `TASK_STATE_WORKING` naming the resources it can see, then a status update
1489
+ `{"resource_id", "uri"}` each time one's free/busy changes, from the same feed and under the same
1490
+ caps as MCP subscriptions, until `TASK_STATE_COMPLETED` (its duration, shutdown) or
1491
+ `TASK_STATE_FAILED` (access withdrawn). Closing the stream ends it; the task is not kept, so
1492
+ `SubscribeToTask` and `GetTask` do not find it. Push notifications are not offered: a tenant's
1493
+ changes reach a URL as signed webhooks.
1494
+
1424
1495
  ### MCP Apps
1425
1496
 
1426
1497
  Read-only tools link to `ui://slotlock/calendar`, a self-contained calendar view that hosts
@@ -1506,7 +1577,9 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1506
1577
  7. Monitor conflicts, refused rules, sync lag, capped maintenance runs, quota errors and confirmation outcomes.
1507
1578
  8. Keep writes confirmation-gated wherever your policy needs a person (`slotlock serve`:
1508
1579
  `SLOTLOCK_CONFIRM_WRITES`, `none` by default).
1509
- 9. Size live updates: at most `maxTotal` backend reads per `pollIntervalMs`.
1580
+ 9. Size live updates: one indexed feed query per second per tenant with an open subscription (four
1581
+ a second while its feed is busy), at most one backend read a second per subscription a change
1582
+ touches, and at most `maxTotal` backend reads per `pollIntervalMs`.
1510
1583
  10. Give each agent its own API key with the narrowest scope and an expiry.
1511
1584
  11. After each deployment, check it end to end with a short-lived admin key, then revoke the key:
1512
1585
  `SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs`, from a clone
@@ -1517,7 +1590,13 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1517
1590
  server (`SLOTLOCK_LIMITS`); behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` and firewall
1518
1591
  the server so nothing reaches it except through that proxy. Embedding the
1519
1592
  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)
1593
+ 13. On an upgrade, migrate first, then, as a library user, run `grantApplicationRole` again right
1594
+ after `applySchema` and before serving traffic: a release that adds a table (the leading-buffer
1595
+ release adds `booking_rule_defaults`, which every booking reads) fails every booking with a
1596
+ permission error until the serving role is granted it. `slotlock migrate` does both. A migration
1597
+ that rebuilds a constraint holds its table's lock for the whole build (see the CHANGELOG's
1598
+ Upgrading notes).
1599
+ 14. Bound how long a transaction may run. A store over your own transaction (a savepoint per call)
1521
1600
  keeps its locks and its snapshot for as long as yours runs, and a hold you confirm inside it is
1522
1601
  judged by the statement's clock, not the transaction's. On PostgreSQL 17 and later, give the
1523
1602
  serving role a `transaction_timeout` above your longest legitimate transaction
package/SPEC.md CHANGED
@@ -245,13 +245,16 @@ envelope (a body over the size limit is HTTP 413); A2A's own errors, below, carr
245
245
  `google.rpc.ErrorInfo` detail and JSON-RPC's (`-32700`, `-32600`, `-32601`, `-32603`) none. An
246
246
  `A2A-Version` other than `1.0`, including none (read as 0.3), is `VersionNotSupportedError`
247
247
  (`-32009`); push-notification methods are
248
- `PushNotificationNotSupportedError` (`-32003`); streaming, task subscription and the extended card
249
- are `UnsupportedOperationError` (`-32004`); a message that is not exactly one data part
248
+ `PushNotificationNotSupportedError` (`-32003`); task subscription and the extended card are
249
+ `UnsupportedOperationError` (`-32004`); a message that is not exactly one data part
250
250
  `{"skill": "<id>", "arguments": {…}}` (`arguments` left out or null is none; any other value that is
251
251
  not an object is not arguments) is `-32602`, as is a `configuration` or `metadata` that is set but
252
252
  is not an object; a rate-limited request is `-32029` with the reason `RATE_LIMITED` and a null id,
253
- answered before its body is read. The server replies with messages and keeps no
254
- tasks, so the core task operations answer as for an unknown task: `GetTask` and `CancelTask` are
253
+ answered before its body is read. `SendStreamingMessage` streams a skill's reply as one message;
254
+ with `slotlock_watch_resources` it streams a watch task (TASK_STATE_WORKING, a status update per
255
+ changed resource, then TASK_STATE_COMPLETED or TASK_STATE_FAILED) that lives only in its stream,
256
+ under the MCP subscription caps; refused before it opens, it is the skill's reply. The server keeps
257
+ no tasks, so the core task operations answer as for an unknown task: `GetTask` and `CancelTask` are
255
258
  `TaskNotFoundError` (`-32001`), `ListTasks` returns an empty page (`pageSize` 1-100, default 50).
256
259
  Their params are checked first, as `SendMessage`'s are: a field the method does not define, a
257
260
  `tenant` other than the caller's, a value of the wrong type (a `status` outside `TaskState`, say), a
@@ -364,9 +367,9 @@ with no remaining command; `internal` tombstones remain authoritative until tena
364
367
  command evaluated after its evidence is gone is decided afresh by the expected-revision guard.
365
368
 
366
369
  The protocol backend remains injectable so a deployment owns credential verification, policy, rate
367
- limits, and networking. The A2A binding is intentionally synchronous and advertises neither
368
- streaming nor push notifications; an embedder must not claim task lifecycle support unless it
369
- persists that lifecycle itself.
370
+ limits, and networking. The A2A binding advertises streaming (a reply as one message, a watch as
371
+ a task that lives only in its stream) and not push notifications; an embedder must not claim
372
+ persisted task lifecycle support unless it persists that lifecycle itself.
370
373
 
371
374
  The optional Node HTTP adapter preserves the Fetch boundary rather than defining another protocol
372
375
  implementation. It constructs callback-visible request URLs from an explicit canonical origin,
package/agent-server.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type SlotlockTraceContext } from './mcp-modern.js';
2
2
  import { type SlotlockApplicationBackend, type SlotlockApplicationOperation } from './application-api.js';
3
+ import { type SlotlockAgentChangeSource } from './change-watch.js';
4
+ import type { SlotlockChangeOrigin } from './event-feed.js';
3
5
  export type { SlotlockTraceContext } from './mcp-modern.js';
4
6
  export { clientSupportsSlotlockMcpApp, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, } from './mcp-app.js';
5
7
  /**
@@ -64,6 +66,11 @@ export interface SlotlockAgentPrincipal {
64
66
  * Unset, `authorize` alone decides.
65
67
  */
66
68
  scopes?: readonly string[];
69
+ /**
70
+ * The API key the credential is, by its id (a UUID), when it is one: the history records it with
71
+ * each change the principal makes (`createSlotlockApiKeyAuthenticator` sets it).
72
+ */
73
+ keyId?: string;
67
74
  }
68
75
  export interface SlotlockAgentInvocationContext {
69
76
  principal: SlotlockAgentPrincipal;
@@ -74,6 +81,11 @@ export interface SlotlockAgentInvocationContext {
74
81
  * backend's spans as its children to follow one agent action across services.
75
82
  */
76
83
  trace?: SlotlockTraceContext;
84
+ /**
85
+ * The surface the call came through (`mcp`, `a2a`, `rest`, `dashboard-assistant`), for a backend
86
+ * to record with its changes (withTenant's `origin`).
87
+ */
88
+ origin?: SlotlockChangeOrigin;
77
89
  }
78
90
  /** A bounded domain failure a backend may deliberately expose through a protocol tool result. */
79
91
  export declare class SlotlockAgentOperationError extends Error {
@@ -182,13 +194,27 @@ export interface SlotlockAgentConfirmationOptions {
182
194
  /**
183
195
  * Live calendar updates over MCP 2026-07-28 `subscriptions/listen`. A client subscribes to calendar
184
196
  * resource URIs (`slotlockCalendarResourceUri`) and receives `notifications/resources/updated` on the
185
- * response stream when a resource's free/busy changes. The server finds changes by re-reading each
186
- * open subscription's resources through the backend every `pollIntervalMs`, re-running `authenticate`
187
- * and `authorize` each time, so a revoked credential or permission ends the stream.
197
+ * response stream when a resource's free/busy changes. With `changes`, the server follows each
198
+ * tenant's change feed (one reader per tenant, shared by all its subscriptions) and re-reads a
199
+ * subscription's resources when a change touches one of them; without it, it re-reads them every
200
+ * `pollIntervalMs`. Each re-read runs `authenticate` and `authorize` again, so a revoked credential
201
+ * or permission ends the stream, and announces only the resources whose free/busy changed.
188
202
  */
189
203
  export interface SlotlockAgentSubscriptionOptions {
190
- /** How often an open subscription re-reads its resources, in ms: 1,000-300,000, default 10,000. */
204
+ /**
205
+ * How often an open subscription re-reads its resources regardless, in ms: 1,000-300,000. Default
206
+ * 10,000, or 15,000 with `changes`, where it is the fallback for what the feed does not carry
207
+ * (bookable hours, a revoked key or permission) and for a feed that stopped.
208
+ */
191
209
  pollIntervalMs?: number;
210
+ /**
211
+ * The change feed to follow (createSlotlockStoreChangeSource, or your own over
212
+ * SlotlockEventFeed.listChanges), polled by one reader per tenant every SLOTLOCK_FEED_BUSY_MS to
213
+ * SLOTLOCK_FEED_IDLE_MS: a change is announced within about a second instead of at the next
214
+ * re-read. A subscription re-reads its resources at most once a second, however many changes
215
+ * touch them.
216
+ */
217
+ changes?: SlotlockAgentChangeSource;
192
218
  /** When the server ends a subscription gracefully, in ms: 60,000-86,400,000, default 900,000. */
193
219
  maxDurationMs?: number;
194
220
  /** How often a quiet stream carries an SSE keep-alive comment, in ms: 1,000-60,000, default 15,000. */
@@ -449,6 +475,8 @@ export declare function invokeSlotlockAgentOperation(args: {
449
475
  options: SlotlockAgentOperationDispatchOptions;
450
476
  /** Validated W3C trace context to hand the backend (see `SlotlockAgentInvocationContext.trace`). */
451
477
  trace?: SlotlockTraceContext;
478
+ /** The surface the call came through, handed to the backend (`SlotlockAgentInvocationContext.origin`). */
479
+ origin?: SlotlockChangeOrigin;
452
480
  }): Promise<SlotlockAgentOperationDispatchResult>;
453
481
  /**
454
482
  * Read an A2A `google.protobuf.Timestamp` in its JSON form, as `ListTasks.statusTimestampAfter`
@@ -478,6 +506,12 @@ export declare function describeSlotlockAgentWrite(args: {
478
506
  input: Record<string, unknown>;
479
507
  signal: AbortSignal;
480
508
  }): Promise<string>;
509
+ /**
510
+ * The A2A skill that watches calendars: `SendStreamingMessage` only. Its stream opens with a task
511
+ * in TASK_STATE_WORKING and carries a status update for each change to a watched resource's
512
+ * free/busy, from the same feed as MCP resource subscriptions, until the watch's duration is up.
513
+ */
514
+ export declare const SLOTLOCK_A2A_WATCH_SKILL = "slotlock_watch_resources";
481
515
  export declare function createSlotlockAgentServer(options: SlotlockAgentServerOptions): {
482
516
  fetch(request: Request): Promise<Response>;
483
517
  manifest: Readonly<Record<string, unknown>>;