slotlock 0.1.1 → 0.2.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,131 @@
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.2.0 - 2026-10-23
7
+
8
+ ### Added
9
+
10
+ - The application API: what an application does through the store, over REST from any language.
11
+ Create resources and set their weekly hours; hold, confirm and release time; book, move and cancel
12
+ reservations by reference and revision; record external calendar coverage; run maintenance. The
13
+ routes sit under `/v1` beside the tools, in the same OpenAPI document, and need an API key with the
14
+ new `admin` scope. They are never MCP tools or A2A skills. `createSlotlockStoreApplicationBackend`
15
+ serves them from the store; `createSlotlockAgentServer` takes them as `application`.
16
+ - The `admin` API key scope: `slotlock key create <name> --scope read,write,admin`, or "Full" in the
17
+ dashboard. New keys still default to read and write, and `SLOTLOCK_AUTH_TOKEN` is never admin: it
18
+ is the token agents are given, and the application API asks no person before it writes.
19
+ - Built-in maintenance. `slotlock serve` maintains every tenant it serves, hourly, one at a time,
20
+ under a per-tenant advisory lock, and logs the counts; `slotlock maintain` runs it once.
21
+ `maintainSlotlockTenant`, `startSlotlockMaintenance` and `withSlotlockMaintenanceLock` do the
22
+ same in a process of your own.
23
+ - `confirmHold` and `releaseHold` take `{ tenantRef }`, so another tenant's hold is not found
24
+ whatever the connection's row-level security.
25
+ - Clients for Python, Go, Rust, Java and .NET (`clients/`): one method per REST operation, the
26
+ application API included, generated from `openapi.json` with hand-written transports. Each runs
27
+ the same scenario against the server image in CI. They are not on their package registries yet.
28
+ - `openapi.json`, the REST API's OpenAPI 3.1 document, in the repository and at
29
+ https://slotlock.pylota.io/openapi.json; `slotlockOpenApi()` builds it.
30
+ - Property-based tests (fast-check) of the engine, time zones, iCalendar parsing and API keys.
31
+ - A `call` event for each MCP `tools/call` and A2A `SendMessage` that names an operation: its
32
+ outcome, duration and W3C trace id. `rest` events carry the duration and trace id too, and
33
+ `slotlock serve` logs both.
34
+ - Releases are opened and merged by a GitHub App once it is set up
35
+ (`node scripts/create-release-app.mjs`), so its checks run without a maintainer approving them.
36
+ Only the commit whose checks passed is merged.
37
+
38
+ ### Changed
39
+
40
+ - `slotlock serve` serves REST by default; `SLOTLOCK_REST_API=off` turns it off.
41
+ - `expandRules` and `findNextAvailableFor` refuse a search window longer than 367 days with
42
+ `invalid_window`, as free/busy already did, and skip a rule that lasts longer than a week, as the
43
+ hours validator refuses one. Fifty daily rules over a century were two million windows of
44
+ synchronous work.
45
+ - Upgrading rebuilds the unique index on booking references
46
+ (`slotlock_reservations_booking_external_ref_key`) to leave calendar event rows out, and drops
47
+ the one it replaces after the new one is built.
48
+ - A cursor 0.1 kept beside a stored series is not resumed if the series is zoned and ends (UNTIL):
49
+ 0.1 read its occurrences as UTC beside the UNTIL, so the cursor may have stopped one occurrence
50
+ early (a booking could then take that time) or gone one past. The next roll searches such a series
51
+ from its start, within the same budget, and keeps a new cursor.
52
+ - Upgrading widens the API key scope constraint in place, and replaces the reservations overlap
53
+ constraint with one that counts every row's turnaround buffer (below). Expired holds in a buffer's
54
+ way are removed; while a live hold or untenanted reservation is in one, migrate stops with their
55
+ ids and changes nothing.
56
+
57
+ ### Fixed
58
+
59
+ - A calendar event's occupation could be freed or moved through the reservation commands
60
+ (`cancelExternalReservation`, `rescheduleExternalReservation`, and the application API's
61
+ reservation routes) by naming the reference the store derives for it: the event stayed active but
62
+ stopped occupying its time. Those commands now leave event rows out. An event freed this way
63
+ before upgrading occupies its time again once it is written again.
64
+ - Maintenance replaced a series' stored window with the rolling horizon even when the stored window
65
+ ended later, deleting the occurrences past it.
66
+ - Free/busy called a window complete when a series started inside it but was stored only from
67
+ later: those occurrences were reported as free time.
68
+ - Upgrading as a non-superuser owner under forced row-level security failed with a raw error (or,
69
+ for some repairs, skipped them): the upgrade's data steps saw no rows. The upgrade now lifts FORCE
70
+ for those steps and records what it lifted, so an upgrade stopped part way is put right by the
71
+ next one, and `slotlock serve` refuses to start while a tenant table is left without FORCE.
72
+ - `expandRules` without a time zone offered windows after the rule's UNTIL.
73
+ - A delayed copy of a create, after the reservation had moved on, answered `idempotency_conflict`
74
+ instead of `stale_revision`.
75
+ - A time zone a calendar names without a VTIMEZONE was read as floating, and compared with UTC
76
+ values as if it were UTC. East of UTC a series lost the occurrence its UNTIL names (in a stored
77
+ series, a booking could then take that time); west of it, a series ran hours past its UNTIL; an
78
+ EXDATE or a moved occurrence's RECURRENCE-ID written in UTC was not found. All-day series had the
79
+ same fault with a UTC UNTIL.
80
+ - A feed with a PERIOD RDATE, or an event with no DTEND, was refused whole; a feed's event that
81
+ ended before it started was refused with `invalid_external_interval` instead of
82
+ `invalid_icalendar`; a document whose root is not a VCALENDAR read as an empty calendar.
83
+ - The 4,000 occurrences a series may pass from its start were shared by every series in a feed, so
84
+ three daily series begun in 2023 refused it for good.
85
+ - An EXDATE left its occurrence in when EXDATEs before it named no occurrence (ical.js passes them
86
+ one per occurrence), or when an RRULE and an RDATE both named it; such an instant was also
87
+ reported twice. A series of RDATEs with no RRULE left out its DTSTART, its first occurrence.
88
+ - An occurrence starting in an hour the clocks skip ended as it started and was left out; it now
89
+ lasts its series' exact length, from its DTEND or its DURATION. Others still end on the wall clock.
90
+ - ical.js matched exceptions and compared UNTIL and EXDATE values with a feed's own VTIMEZONE for an
91
+ IANA name, while Slotlock read the same times through Intl: under a stale definition, a moved
92
+ occurrence was found in the feed and lost from the series stored from it. Such a name is read
93
+ through Intl throughout.
94
+ - A series with two RRULEs lost occurrences once either rule ended: ical.js dropped the rule after
95
+ an ended first one, or the one before any other.
96
+ - A DATE EXDATE of a timed series excluded every occurrence that day from a feed and none from the
97
+ series stored from it; both now exclude the occurrence at the series' start time that day.
98
+ - A floating UNTIL was read as UTC beside a zoned DTSTART. A RECURRENCE-ID written in another zone
99
+ than its series' DTSTART did not find its occurrence, and a `RANGE=THISANDFUTURE` one written in
100
+ UTC moved later occurrences by the wrong amount.
101
+ - `parseTrustedICalendarEvents` did not read times as `parseICalendarChanges` does: a stored
102
+ all-day series kept an occurrence the feed's UTC EXDATE excludes. An event it cannot hold (no
103
+ length, say) is now `invalid_icalendar`, not `invalid_external_interval`.
104
+ - A feed naming thousands of time zones it does not define (50,000 spellings of one zone took
105
+ seconds) is refused past 100, as one defining more than 100 is; a feed listing too many EXDATE
106
+ values is refused before they are read, not after.
107
+ - A request body sent without `Content-Length` (chunked) was read whole before the size limit
108
+ applied, so any key holder could make the server hold gigabytes. It is refused as soon as it
109
+ passes `maxRequestBytes`. The Node adapter no longer aborts a request whose body the server stops
110
+ reading; it discards the rest once it has answered.
111
+ - `close()` while the Node adapter was still starting left `listen()` pending forever.
112
+ - On the Workers dashboard, `/dashboard/sign-in/` and `/dashboard/callback/` (with a trailing slash)
113
+ were outside the global sign-in rate limit.
114
+ - The confirmation question for a create did not say that a transparent booking leaves the time
115
+ free, or name the other fields it sets.
116
+ - `arguments` that were not an object (a string, an array) ran the operation with none; they are
117
+ now `-32602` on MCP and an invalid message on A2A. Left out or null is still none.
118
+ - A rate-limited A2A request was answered with an MCP error over HTTP 429; it is now A2A's 200
119
+ envelope with a `RATE_LIMITED` ErrorInfo. `Content-Type` is matched without regard to case.
120
+ - Calls refused before they ran (a client that cannot confirm, a malformed A2A message, a REST body
121
+ that is not JSON, too large or not an object) were not reported to `onEvent`. On MCP 2026-07-28
122
+ a confirmation was reported after the call it decided; it now comes first, as elsewhere.
123
+ - An async `onEvent`, `onError` or maintenance `onPass` that rejected was an unhandled rejection,
124
+ which ends a Node process by default; an `onPass` that threw made a pass that ran count as failed.
125
+ - A hold could take the time a reservation's `bufferAfterMs` keeps busy, and a buffered reservation
126
+ could be booked over a hold: the buffer bound only tenant-owned reservations to each other.
127
+ - `confirmHold` with the id of a reservation that has an external reference answered as if it had
128
+ confirmed a hold, describing it without its reference, revision or buffer. It is now
129
+ `hold_not_found`.
130
+
6
131
  ## 0.1.1 - 2026-10-22
7
132
 
8
133
  ### Changed
package/CONTRIBUTING.md CHANGED
@@ -16,7 +16,8 @@ npm test # tests, build, then the packed-artifact verification
16
16
  ```
17
17
 
18
18
  Database suites (`*.integration.test.ts`) run when `DATABASE_URL` points at a PostgreSQL 16
19
- database whose connecting role can create roles and extensions, and are skipped otherwise:
19
+ database whose connecting role can create roles, databases and extensions, and are skipped
20
+ otherwise (the upgrade suite builds a database of its own and drops it):
20
21
 
21
22
  ```sh
22
23
  DATABASE_URL=postgresql://slotlock_dev:dev-only-password@localhost:5432/slotlock_test npm test
@@ -29,6 +30,11 @@ role named `slotlock` is the package's schema, which `"$user"` puts first on the
29
30
  command-line tool redacts the database password from everything it prints, so a short or
30
31
  product-like password rewrites the output the tests read.
31
32
 
33
+ Property suites (`*.property.test.ts`) check the engine, time zones, iCalendar parsing and API
34
+ keys on input [fast-check](https://fast-check.dev/) generates, with a new seed each run. A failure
35
+ prints the seed and the shrunk counterexample; add `{ seed, path }` from it to that `fc.assert` to
36
+ replay it, then keep the counterexample as an ordinary test once it is fixed.
37
+
32
38
  ## Sign off your commits
33
39
 
34
40
  Slotlock uses the [Developer Certificate of Origin](https://developercertificate.org/) instead of a
@@ -71,21 +77,24 @@ that section becomes the release notes.
71
77
 
72
78
  To release, run **Actions > Release a version** with the version (for example `0.1.1`). It sets the
73
79
  version in `package.json`, `package-lock.json`, `server.json` and `slotlock.manifest.json` and dates
74
- the `## Unreleased` heading (`scripts/prepare-release.sh`) and opens the pull request. GitHub holds
75
- the checks of a pull request a workflow opens until someone with write access approves them: open it
76
- and click **Approve workflows to run**. The run then merges it once CI and DCO pass on it, and starts
77
- `tag-release.yml`, which tags the release commit. The tag starts
78
- `release.yml` (the suite, then npm and the GitHub release) and `image.yml` (the image, then the MCP
79
- Registry). npm holds each staged version until a maintainer approves it with two-factor
80
- authentication (GOVERNANCE.md); the Release run's summary gives the command. A version merged to
81
- `main` by hand, with its heading dated, is released the same way. After a failed run, run it again
82
- with the same version: it keeps the release branch the failed run left when that already holds this
83
- release, so checks approved there still count, replaces it otherwise, and reuses its pull request
84
- (`scripts/open-release-pr.sh`).
85
-
86
- GitHub lets a workflow open pull requests only once an admin allows it (Settings > Actions >
87
- General > Workflow permissions); `scripts/configure-github.sh` sets it, and the run says so when it
88
- is off.
80
+ the `## Unreleased` heading (`scripts/prepare-release.sh`), opens the pull request, merges it once CI
81
+ and DCO pass on it, and `tag-release.yml` tags the release commit. The tag starts `release.yml` (the
82
+ suite, then npm and the GitHub release) and `image.yml` (the image, then the MCP Registry). npm holds
83
+ each staged version until a maintainer approves it with two-factor authentication (GOVERNANCE.md);
84
+ the Release run's summary gives the command. A version merged to `main` by hand, with its heading
85
+ dated, is released the same way. After a failed run, run it again with the same version: it keeps
86
+ the release branch the failed run left when that already holds this release, so checks already run
87
+ there still count, replaces it otherwise, and reuses its pull request (`scripts/open-release-pr.sh`).
88
+
89
+ The pull request is opened and merged by the release app, a GitHub App that can only write this
90
+ repository's contents and pull requests. An owner of the organization creates it once with
91
+ `node scripts/create-release-app.mjs` (click **Create GitHub App**, then install it on `slotlock`
92
+ only); the script checks the installation and stores the app's key in the `release-app` environment,
93
+ which only `main` can use. Without the app, the workflow opens the pull request as
94
+ github-actions[bot], whose checks GitHub holds until someone with write access approves them on the
95
+ pull request (**Approve workflows to run**). GitHub lets a workflow open pull requests at all only
96
+ once an admin allows it (Settings > Actions > General > Workflow permissions);
97
+ `scripts/configure-github.sh` sets it, and the run says so when it is off.
89
98
 
90
99
  When a run needs a step npm or GitHub keeps for a person (the first npm version, the npm trusted
91
100
  publisher, the image's visibility, and the deploy key `tag-release.yml` pushes tags with), the
package/README.md CHANGED
@@ -37,6 +37,7 @@ docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/
37
37
  | --- | --- |
38
38
  | `http://localhost:8080/mcp` | MCP, with `Authorization: Bearer <SLOTLOCK_AUTH_TOKEN>` |
39
39
  | `http://localhost:8080/a2a` | A2A 1.0, same token; card at `/.well-known/agent-card.json` |
40
+ | `http://localhost:8080/v1/…` | [REST](#rest-and-openapi): the tools, and the [application API](#application-api) for admin keys; described at `/openapi.json` |
40
41
  | `http://localhost:8080/healthz` | Readiness, including the database |
41
42
 
42
43
  - The server runs as a role that owns nothing and cannot bypass row-level security; a one-off
@@ -85,6 +86,7 @@ Give each agent its own key instead of sharing the server token:
85
86
  ```sh
86
87
  slotlock key create "Booking agent"
87
88
  slotlock key create "Availability bot" --scope read --expires-in-days 90
89
+ slotlock key create "Booking site" --scope read,write,admin
88
90
  slotlock key list
89
91
  slotlock key rotate <id>
90
92
  slotlock key revoke <id>
@@ -92,8 +94,8 @@ slotlock key revoke <id>
92
94
 
93
95
  - A key (`slk_…`) is shown once and stored only as a SHA-256. Send it as `Authorization: Bearer`.
94
96
  - It acts in the tenant it was created for (`SLOTLOCK_TENANT`), so one server serves many tenants.
95
- - `read` covers the five tools that only look; `write` covers create, update and delete. Default:
96
- both.
97
+ - `read` covers the five tools that only look; `write` covers create, update and delete; `admin`
98
+ covers the [application API](#application-api), which no agent needs. Default: read and write.
97
99
  - `rotate` keeps the key's id, scopes and bookings; `revoke` stops it on its next request.
98
100
  - A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can
99
101
  verify offline.
@@ -265,7 +267,8 @@ export const slot = findNextAvailable({
265
267
  // slot: 2026-09-14 10:00–12:00 UTC, the first two free hours after the busy one
266
268
  ```
267
269
 
268
- A rule `expandRules` cannot evaluate adds no availability; it never invents a slot.
270
+ A rule `expandRules` cannot evaluate adds no availability; it never invents a slot. A search window
271
+ may be 367 days at most (`invalid_window`), here and in `findNextAvailableFor`.
269
272
 
270
273
  ## Set up PostgreSQL
271
274
 
@@ -411,26 +414,29 @@ export async function scheduleWeeklyInspection(
411
414
  ```
412
415
 
413
416
  Occurrences are stored for a window of at most 367 days and 2,000 occurrences; maintenance rolls
414
- the window forward. Past it, free/busy reports the time as unproven.
417
+ the window forward, never moving its end back. Outside it, before or after, free/busy reports the
418
+ time as unproven.
415
419
 
416
420
  A rule is expanded within a budget: 50,000 steps and a second for a feed (the whole read, from its
417
421
  text), or for a stored series as maintenance rolls it; half of that for one event as it is written.
418
- A feed's own time zones (VTIMEZONE) count against the same budget. A feed may carry 100 time zones,
419
- 1,000 of their observances and 2,000 RDATE values in them, and 20,000 RDATE and EXDATE values in
420
- its events; a property's name and parameters may take 8 KB and list 100. A SECONDLY to WEEKLY step,
421
- a `DURATION`, or an event's length may be a century at most. A calendar that needs more, such as a
422
- rule that never occurs (`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`), is refused: `invalid_event` on a
423
- write, `invalid_icalendar` from a feed.
424
-
425
- A feed's rules are expanded from their start. A stored series keeps where its last expansion
426
- stopped, and the next one resumes there, so a daily series keeps rolling for as long as it runs:
427
- its budget goes on the window, not on the years before it. A write that changes a series searches
428
- from its start, and resumes from where it stopped only if that search is over the budget. A place
429
- serves only the rule, start and time zone it was kept for, read by the same version of ical.js, and
430
- never lies past an occurrence that could still reach the window (one within the event's length and
431
- a day of it, or one an exception moves into it). Otherwise the series is searched from its start,
432
- within the same budget. During maintenance, a stored series that outgrows the budget is counted as
433
- `refused` (its ids in `refusedEventIds`) and keeps its old coverage.
422
+ A feed's own time zones (VTIMEZONE) count against the same budget. A feed may carry 100 time zones
423
+ and name 100 it does not define, 1,000 observances and 2,000 RDATE values in the zones it carries,
424
+ and 20,000 RDATE and EXDATE values in its events; a property's name and parameters may take 8 KB
425
+ and list 100. A SECONDLY to WEEKLY step, a `DURATION`, or an event's length may be a century at
426
+ most. A calendar that needs more, such as a rule that never occurs
427
+ (`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`), is refused: `invalid_event` on a write, `invalid_icalendar`
428
+ from a feed.
429
+
430
+ A feed's rules are expanded from their start, and each series may pass 4,000 occurrences on the way
431
+ (a daily one, about 11 years) whatever the feed's other series take. A stored series keeps where
432
+ its last expansion stopped, and the next one resumes there, so a daily series keeps rolling for as
433
+ long as it runs: its budget goes on the window, not on the years before it. A write that changes a
434
+ series searches from its start, and resumes from where it stopped only if that search is over the
435
+ budget. A place serves only the rule, start and time zone it was kept for, read by the same version
436
+ of ical.js, and never lies past an occurrence that could still reach the window (one within the
437
+ event's length and a day of it, or one an exception moves into it). Otherwise the series is searched
438
+ from its start, within the same budget. During maintenance, a stored series that outgrows the budget
439
+ is counted as `refused` (its ids in `refusedEventIds`) and keeps its old coverage.
434
440
 
435
441
  ### External calendars
436
442
 
@@ -440,6 +446,24 @@ Slotlock gives them neutral boundaries:
440
446
  - `normalizeExternalCalendarChange` for webhook changes; `parseICalendarChanges` and `emitICalendar`
441
447
  for bounded iCalendar in and out; `emitITipCalendar` for invitations.
442
448
  - All-day events become whole local days in the calendar's time zone; a feed without one is refused.
449
+ - A TZID Intl knows is read as that IANA zone, whether or not the feed defines it (a VTIMEZONE's
450
+ own rules for such a name are not used); one only the feed defines, such as a Windows name, is
451
+ read from its VTIMEZONE. A floating time is read in the calendar's zone. So an UNTIL, EXDATE,
452
+ RDATE or RECURRENCE-ID written in UTC or another zone names the occurrence it means, a floating
453
+ UNTIL is read on the series' own clock, and a `RANGE=THISANDFUTURE` exception moves every later
454
+ occurrence by its own shift.
455
+ - An event with no length (a DATE-TIME DTSTART without DTEND or DURATION) occupies no time and is
456
+ left out. A PERIOD RDATE ends where its period does. A document whose root is not a VCALENDAR, or
457
+ an event that ends before it starts, is refused as `invalid_icalendar`.
458
+ - A series' DTSTART is its first occurrence, with or without an RRULE; every RRULE is expanded; an
459
+ instant two of them, or an RRULE and an RDATE, name is one occurrence; every EXDATE excludes the
460
+ occurrence it names (RFC 5545 §3.8.5). A DATE EXDATE of a timed series names the occurrence at the
461
+ series' start time that day.
462
+ - An occurrence ends on the wall clock, as calendars show it (00:30-02:30 lasts three hours the
463
+ night the clocks go back). One that starts in an hour the clocks skip lasts its series' exact
464
+ length instead, from the offset before the gap (RFC 5545 §3.3.5).
465
+ - `parseTrustedICalendarEvents` reads times as `parseICalendarChanges` does, so a series stored from
466
+ it occupies what the feed reports.
443
467
  - `recordCalendarCoverage` marks how much of a feed is read. Pass the sources a resource depends on as
444
468
  `requiredSources`, and any coverage short of `complete` means the time is not proven free.
445
469
 
@@ -625,10 +649,10 @@ on stderr.
625
649
 
626
650
  | Where | Error |
627
651
  | --- | --- |
628
- | HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit |
629
- | MCP | `-32602` unknown tool or altered confirmation, `-32603` server fault, `-32601` unknown method |
652
+ | HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit (MCP, REST) |
653
+ | MCP | `-32602` unknown tool, `arguments` that are not an object, or altered confirmation; `-32603` server fault; `-32601` unknown method |
630
654
  | 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) |
655
+ | 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
656
 
633
657
  ### Authentication and OAuth discovery
634
658
 
@@ -655,6 +679,8 @@ With `confirmation`, a listed write first asks the person a yes/no question that
655
679
  will change, such as `Book vehicle-42 for 2027-03-29 10:00–11:00 (Europe/London): "Vehicle
656
680
  handover".` Only an explicit yes runs the write.
657
681
 
682
+ - A booking that leaves the time free (`transparency: 'transparent'`) says so, and a write names
683
+ the other fields it sets (`also description, attendees`) without quoting them.
658
684
  - Facts come first: the resource by its own reference, times in its own zone, and an event as it
659
685
  stands now, read through your backend's `describeResource` and `getEvent` when the caller may
660
686
  read them (within 2 seconds in all). Otherwise things are named by id, and each time carries its
@@ -685,10 +711,29 @@ returns busy time and coverage for the next `resourceWindowDays` (30). `subscrip
685
711
  2026-07-28 results carry cache hints: discovery and tool lists are public for an hour, free/busy is
686
712
  never cached. W3C `traceparent`, `tracestate` and `baggage` reach your backend as `context.trace`.
687
713
 
714
+ `onEvent` hears about every call that names an operation, so you can count, time and correlate them:
715
+
716
+ - `{ type: 'call', protocol, operation, outcome, durationMs, traceId? }` for an MCP `tools/call` or
717
+ A2A `SendMessage`; `outcome` is `ok`, the refusal's code (`overlap`, `rate_limited`, …), or why
718
+ it was refused before it ran: `invalid_arguments`, `missing_capability` (a guarded write from a
719
+ client that cannot confirm), `invalid_message` or `invalid_message_parameters` (A2A).
720
+ - `{ type: 'rest', operation, status, durationMs, traceId? }` for a REST call, or one refused before
721
+ it ran: a body that is not JSON (415, 400), too large (413) or not an object (400).
722
+ - `{ type: 'confirmation', operation, outcome }` for each confirmation, before the call it decides.
723
+ - A request refused before its operation is read is not reported: a missing credential, the rate
724
+ limit, an `A2A-Version` other than 1.0, a JSON-RPC envelope that does not parse, a path no route
725
+ has.
726
+ - `traceId` is the trace id of a valid `traceparent` from `_meta` or the header. Arguments, callers,
727
+ the parent span, `tracestate` and `baggage` are never reported, and an `onEvent` or `onError` that
728
+ throws, or returns a promise that rejects, changes nothing a client sees. Resource reads and
729
+ subscription polls are not reported.
730
+
731
+ `slotlock serve` writes each as a JSON line (`"event":"call"`, `duration_ms`, `trace_id`).
732
+
688
733
  ### REST and OpenAPI
689
734
 
690
- `rest: true` (`SLOTLOCK_REST_API=on` for `slotlock serve`) also serves each tool as plain HTTP,
691
- described by an OpenAPI 3.1 document at `/openapi.json`:
735
+ `rest: true` also serves each tool as plain HTTP, described by an OpenAPI 3.1 document at
736
+ `/openapi.json`. `slotlock serve` has it on; `SLOTLOCK_REST_API=off` turns it off.
692
737
 
693
738
  | Request | Tool |
694
739
  | --- | --- |
@@ -706,14 +751,66 @@ curl "http://localhost:8080/v1/resources" -H "Authorization: Bearer $SLOTLOCK_AP
706
751
  ```
707
752
 
708
753
  - `GET` and `DELETE` take the tool's arguments as query parameters (a list repeats:
709
- `resource_ids=a&resource_ids=b`); `POST` and `PATCH` take a JSON body. The event id is in the path.
754
+ `resource_ids=a&resource_ids=b`); `POST`, `PUT` and `PATCH` take a JSON body. Ids are in the path;
755
+ percent-encode one that holds `/`.
710
756
  - Keys, scopes, `authorize` and the rate limit apply as over MCP. The body is the tool's output, or
711
757
  `{"error":{"code":…}}` with the matching HTTP status.
712
758
  - REST cannot ask a person, so a write listed in `confirmation` answers `428 confirmation_required`.
713
759
  - There are no CORS headers: call it from a server, not a browser.
760
+ - From Python, Go, Rust, Java or .NET, use the [clients](clients/README.md): one method per
761
+ operation, the application API included, each tested against the server in CI. For another
762
+ language, generate one from [`openapi.json`](openapi.json) (or a server's `/openapi.json`) with any
763
+ OpenAPI 3.1 generator.
764
+
765
+ ### Application API
766
+
767
+ What the application around the agents does, from any language: the store's resource, hours, hold,
768
+ reservation, coverage and maintenance calls, as REST beside the tools. It needs a key with the
769
+ `admin` scope ("Full" in the dashboard); the server's own `SLOTLOCK_AUTH_TOKEN`, the token agents
770
+ are given, reads and writes but is never admin. It is never an MCP tool or an A2A skill, and never
771
+ waits for a confirmation.
772
+
773
+ | Request | Does |
774
+ | --- | --- |
775
+ | `POST /v1/resources` | Create the resource with this `external_ref`, or update its `timezone` |
776
+ | `GET /v1/resources/{resource_id}` | One resource, with its own weekly hours |
777
+ | `PUT /v1/resources/{resource_id}/availability` | Its own weekly hours; `[]` never bookable, `null` the server's |
778
+ | `PUT /v1/availability` | Every resource's weekly hours at once |
779
+ | `POST /v1/holds` | Hold a window for `ttl_seconds` (up to 7 days) |
780
+ | `POST /v1/holds/{hold_id}/confirm` | Turn the hold into a reservation |
781
+ | `DELETE /v1/holds/{hold_id}` | Release the hold |
782
+ | `PUT /v1/reservations/{reservation_ref}` | Book (`revision: 1`) or move (the current revision plus 1) |
783
+ | `DELETE /v1/reservations/{reservation_ref}?revision=…` | Cancel, at the current revision plus 1 |
784
+ | `PUT /v1/resources/{resource_id}/coverage/{source}` | Record that `source`'s events are complete for a window |
785
+ | `POST /v1/maintenance` | Run [maintenance](#operations) for the key's tenant |
786
+
787
+ ```sh
788
+ curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
789
+ -H "Authorization: Bearer $SLOTLOCK_ADMIN_KEY" -H "Content-Type: application/json" \
790
+ -d '{"resource_id":"<id>","start":"2027-03-29T09:00:00Z","end":"2027-03-31T09:00:00Z",
791
+ "buffer_after_minutes":60,"revision":1}'
792
+ ```
793
+
794
+ - A taken window answers `409 overlap`. `buffer_after_minutes` keeps the time after a reservation
795
+ busy for holds and other reservations alike.
796
+ - Every write is safe to retry: the same reservation at its current revision answers
797
+ `replayed: true`, a confirmed hold confirms again, and a cancelled reference stays cancelled (a
798
+ late booking for it answers `409`). A late copy of a booking or a move that has since been
799
+ superseded answers `409 stale_revision`.
800
+ - The time a calendar event occupies is the event's: reservation calls never find, move or cancel
801
+ it, whatever reference they name. Change the event instead.
802
+ - The tenant is the key's. Another tenant's resource or hold is `404`, whatever the database role.
803
+ - With the library, pass `application` beside `rest: true`:
804
+ `{ backend: createSlotlockStoreApplicationBackend(store, { maxTenantResources }), authorize }`.
805
+ A principal with `scopes` needs `admin`; then `authorize` decides.
714
806
 
715
807
  ## Connect an agent
716
808
 
809
+ MCP and A2A are wire protocols, so an agent in any language connects with its own MCP or A2A client
810
+ to `/mcp` or `/a2a`, the API key as a bearer token. The examples below use TypeScript. Code that
811
+ calls Slotlock directly, without an agent, can use the [REST clients](clients/README.md) for
812
+ Python, Go, Rust, Java and .NET.
813
+
717
814
  ### MCP (any client)
718
815
 
719
816
  <!-- example: examples/mcp-client.ts#mcp -->
@@ -878,62 +975,37 @@ and never writes.
878
975
 
879
976
  ## Operations
880
977
 
881
- Run this at least daily for every tenant:
978
+ Every tenant needs maintenance at least daily: it extends recurring events to the rolling horizon
979
+ and prunes what the retention window no longer needs, which is what returns agent quota.
980
+
981
+ - `slotlock serve` maintains `SLOTLOCK_TENANT` when it starts and every tenant whose key it serves,
982
+ then each hourly, one tenant at a time. It logs a `maintenance` line with counts.
983
+ - `slotlock maintain` runs one tenant's maintenance now, for cron or after a bulk import. An admin
984
+ key can call `POST /v1/maintenance`.
985
+ - Each pass holds a per-tenant PostgreSQL advisory lock, so servers and cron jobs never repeat each
986
+ other's work; a pass that finds the lock taken does nothing.
987
+
988
+ In a process of your own:
882
989
 
883
990
  <!-- example: examples/maintenance.ts#maintenance -->
884
991
  ```ts
885
- import {
886
- type CalendarHorizonRollCursor,
887
- type CalendarHorizonRollResult,
888
- type SlotlockStore,
889
- calendarEventRollingHorizon,
890
- } from 'slotlock';
891
-
892
- const MAX_BATCHES = 10;
992
+ import { type SlotlockStore, maintainSlotlockTenant } from 'slotlock';
893
993
 
894
994
  export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
895
- const window = calendarEventRollingHorizon();
896
- let extended = 0;
897
- let conflicts = 0;
898
- let refused = 0;
899
- let horizonCapped = true;
900
- let after: CalendarHorizonRollCursor | null = null;
901
- for (let batch = 0; batch < MAX_BATCHES; batch++) {
902
- const from = after;
903
- const roll: CalendarHorizonRollResult = await store.withTenant(tenantRef, (tenant) =>
904
- tenant.rollCalendarEventHorizon({ tenantRef, window, ...(from ? { after: from } : {}) }),
905
- );
906
- extended += roll.extended;
907
- conflicts += roll.conflicts;
908
- refused += roll.refused;
909
- after = roll.next;
910
- if (!after) {
911
- horizonCapped = false;
912
- break;
913
- }
914
- }
915
-
916
- let pruned = 0;
917
- let retentionCapped = true;
918
- for (let batch = 0; batch < MAX_BATCHES; batch++) {
919
- const prune = await store.withTenant(tenantRef, (tenant) =>
920
- tenant.pruneCalendarEventRetention({ tenantRef }),
921
- );
922
- pruned += prune.commandsDeleted + prune.tombstonesDeleted;
923
- if (!prune.hasMore) {
924
- retentionCapped = false;
925
- break;
926
- }
927
- }
928
- return { extended, conflicts, refused, pruned, capped: horizonCapped || retentionCapped };
995
+ // Extends recurring events to the rolling horizon, then prunes past the retention window, each in
996
+ // at most ten batches. `capped: true` means work was left: run another pass.
997
+ return maintainSlotlockTenant(store, tenantRef, { batches: 10 });
929
998
  }
930
999
  ```
931
1000
 
932
- - **Run maintenance at least daily for every tenant.** Forced RLS hides other tenants from the
933
- application role, so iterate your own tenant list.
934
- - **Page the roll with `next`.** Pass each batch's `next` as `after` to the next batch of the same
935
- pass. A series left in conflict or refused keeps its place at the front of the order, so without
936
- `after` every later batch would examine it again.
1001
+ - **Every tenant.** Forced RLS hides other tenants from the application role, so iterate your own
1002
+ tenant list. `startSlotlockMaintenance({ store, sql, tenants, intervalMs })` is the scheduler
1003
+ `serve` uses, lock included; `withSlotlockMaintenanceLock` is the lock alone.
1004
+ - **Capped passes.** A capped pass that extended and pruned nothing met only series it cannot extend
1005
+ (counted in `conflicts` and `refused`): another pass would too, so wait for the next interval.
1006
+ - **Paging the roll yourself.** `rollCalendarEventHorizon` returns `next`; pass it as `after` to the
1007
+ next batch of the same pass. A series left in conflict or refused keeps its place at the front of
1008
+ the order, so without `after` every later batch would examine it again.
937
1009
  - **Quotas.** Each agent principal may retain 1,000 event identities (active plus cancelled) and
938
1010
  10,000 idempotency commands, of which 9,000 can be creates or updates, so a cancel always has
939
1011
  room. Adjust with `agentOwnerEventQuota` and `agentOwnerCommandQuota` on `createSlotlockStore`.
@@ -964,6 +1036,10 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
964
1036
  8. Keep writes confirmation-gated wherever your policy needs a person.
965
1037
  9. Size live updates: at most `maxTotal` backend reads per `pollIntervalMs`.
966
1038
  10. Give each agent its own API key with the narrowest scope and an expiry.
1039
+ 11. After each deployment, check it end to end with a short-lived admin key, then revoke the key:
1040
+ `SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs` checks health,
1041
+ discovery, MCP, A2A, holds and reservations (in a resource named `slotlock-smoke`, 400 days
1042
+ ahead, cancelled after) and the 401, 409, 413 and 415 refusals.
967
1043
 
968
1044
  ## Supported versions
969
1045
 
package/SECURITY.md CHANGED
@@ -7,9 +7,10 @@ documented breaking changes; security releases will identify the lowest safe ver
7
7
 
8
8
  ## Report a vulnerability
9
9
 
10
- Do not open a public issue. Report privately through GitHub's private vulnerability reporting on the
11
- repository that publishes the package: open its **Security** tab and choose **Report a
12
- vulnerability**. The maintainers listed in [MAINTAINERS.md](./MAINTAINERS.md) receive the report.
10
+ Do not open a public issue. Report privately through GitHub's private vulnerability reporting:
11
+ <https://github.com/PILOTAAI/slotlock/security/advisories/new> (the repository's **Security** tab,
12
+ **Report a vulnerability**). The maintainers listed in
13
+ [MAINTAINERS.md](https://github.com/PILOTAAI/slotlock/blob/main/MAINTAINERS.md) receive the report.
13
14
  Include:
14
15
 
15
16
  - the affected version and surface;
@@ -18,7 +19,8 @@ Include:
18
19
  - any known mitigations.
19
20
 
20
21
  Do not include real customer, credential, calendar, or booking data. We will acknowledge a report
21
- within three business days and coordinate disclosure after a fix is available.
22
+ within three business days and coordinate disclosure after a fix is available. Fixed vulnerabilities
23
+ are published as advisories at <https://github.com/PILOTAAI/slotlock/security/advisories>.
22
24
 
23
25
  ## Security model
24
26
 
@@ -28,6 +30,16 @@ PostgreSQL exclusion constraint. Tenant policies are forced and fail closed when
28
30
  context is missing. Embedding applications remain responsible for authentication, authorization,
29
31
  provider-secret storage, webhook verification, network egress, and erasure.
30
32
 
33
+ Upgrading repairs data across tenants (expired holds in a turnaround buffer, duplicate resources,
34
+ archives and event details from earlier versions), which forced policies would hide from the owning
35
+ role. `applySchema` therefore lifts FORCE from the tables that have it for its data steps and puts
36
+ it back on exactly those, inside its one locked transaction: a failure rolls the lift back with the
37
+ rest. Run statement by statement instead (psql without `--single-transaction`), a refused step
38
+ stops the run with FORCE lifted. The tables lifted are recorded in
39
+ `slotlock.deployment_lifted_row_security`, so the next deployment, from any session, puts FORCE back
40
+ on each of them, and until it does `slotlock serve` refuses to start (`row_security_lifted`). Run
41
+ the deployment DDL through `applySchema` or `slotlock migrate`.
42
+
31
43
  Application traffic must use a role that cannot leave forced RLS. `grantApplicationRole` grants
32
44
  such a role schema usage and DML on the tenant tables, and first refuses the known ways out, whether
33
45
  the role holds them itself, through a role it belongs to, or through `PUBLIC`, and names the ones
@@ -97,3 +109,27 @@ embedder's `authenticate` must verify every token, including its audience. Embed
97
109
  bearer-token verification, per-principal authorization, distributed rate limiting, TLS termination,
98
110
  and a readiness check. Do not place provider credentials or raw calendar content in authentication,
99
111
  authorization, rate-limit, or health callbacks.
112
+
113
+ ## API keys and the application API
114
+
115
+ An API key is `slk_`, 40 base62 characters from the operating system's random source (about 238
116
+ bits) and a 6-character checksum, and the database keeps only its SHA-256. A slow password hash
117
+ (bcrypt, scrypt, Argon2) defends a secret a person chose against offline guessing; nobody can guess
118
+ 238 random bits, so a fast hash
119
+ loses nothing and lets every request look a key up by its digest in one indexed read. Code scanners
120
+ that treat every secret as a password report this as weak hashing (CodeQL
121
+ `js/insufficient-password-hash`); it is the design, as for GitHub's and Stripe's tokens.
122
+
123
+ A key acts only in the tenant it was created for. `read` and `write` cover the agent tools; `admin`
124
+ covers the application API (resources, hours, holds, reservations, coverage, maintenance), which is
125
+ never offered to agents over MCP or A2A. Every application operation checks the tenant itself, not
126
+ only through row-level security: another tenant's resource or hold is not found even on a connection
127
+ that bypasses RLS.
128
+
129
+ ## Dependencies
130
+
131
+ The published package depends on `ical.js`, `rrule` and `zod`, and on `postgres` as a peer; the
132
+ image adds nothing else. The documentation site (`site/`) is built with Astro and Starlight, and
133
+ those development dependencies never reach the package or the image. `braces`, which the site's
134
+ `starlight-llms-txt` reaches through `micromatch`, has an open denial-of-service advisory with no fixed
135
+ release; it only expands the site's own glob patterns at build time, so no outside input reaches it.