slotlock 0.5.3 → 0.6.1

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,8 +3,127 @@
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.1 - 2026-11-08
7
+
8
+ ### Changed
9
+
10
+ - Dashboard: a new look on one set of design tokens, in light and dark. Calmer surfaces (hairline
11
+ borders, no drop shadows), a monospace label scale for table heads and stats, black primary
12
+ buttons, status pills with a dot, and empty states that name the one next step. Long names,
13
+ references, URLs and ids stay on one line with the whole value in a tooltip (and wrap on a
14
+ phone); table dates show the day, with the full UTC time on hover. On a phone the pages sit
15
+ under a top bar with a scrolling tab strip and a sign-out button. A skip link, a live region for
16
+ Copy, Escape and click-outside closing for Rotate, Revoke and Delete (whose button takes focus),
17
+ and reduced motion are respected. The key list names who created a key under its name instead
18
+ of in a column of its own. Routes, form fields, CSRF and origin checks, sessions and the
19
+ Content-Security-Policy are unchanged; the dashboard still loads only its own stylesheet and
20
+ script, and works without script.
21
+
22
+ ### Fixed
23
+
24
+ - Dashboard: Rotate and Revoke on API keys, Delete on webhooks and Remove on the waitlist open
25
+ their confirmation in place, growing the row. It floated below the row inside the table's
26
+ scrolling box, which clipped it: on an account with one key, Revoke opened a button that could
27
+ not be pressed.
28
+
29
+ ## 0.6.0 - 2026-10-29
30
+
31
+ ### Added
32
+
33
+ - `invalid_arguments` from argument validation names the fields it refused: `issues`, up to 20
34
+ `{path, message}` (the field's path in the arguments, outermost first; `[]` is the arguments as a
35
+ whole). REST adds it to the problem details beside `code` (documented in openapi.json's `Error`),
36
+ MCP and A2A to `error`. Additive: `code` and the status are unchanged.
37
+ `invokeSlotlockAgentOperation` returns it too (`SlotlockArgumentIssue`).
38
+ - `slotlock serve` logs one `serving_as_schema_owner` warning at startup when its role owns the
39
+ `slotlock` schema: row-level security does not confine the owner on tables that do not force
40
+ it. It still serves.
41
+
42
+ ### Fixed
43
+
44
+ - Security: a coverage source's `source_id` could be confirmed by guessing. 0.5.3 named a source on the feed
45
+ by `src_` and 24 hex digits of an unkeyed SHA-256 of the tenant and the source, so anyone who saw
46
+ a change or a webhook and knew the tenant could test a guessed source offline: an address such as
47
+ `google:alice.smith@example.com`, say. A source's `source_id` is now random (`src_` and 32 hex
48
+ digits, 122 bits from `gen_random_uuid()`), stored with the source in
49
+ `calendar_coverage.source_id` when it is first recorded for the resource and kept until it is
50
+ deleted. Nothing outside the database can work it out; the tenant reads it beside the source from
51
+ `listCoverage` and `slotlock_get_coverage`.
52
+ - Security: changes an earlier release wrote still carried the source. Before 0.5.3 `coverage.stale` had the
53
+ source itself as `subject` and `data.source` (an iCalendar URL with its token) and the error read
54
+ from it as `data.last_error`, and `listChanges`, the history and webhooks that had not received it
55
+ served it until it was pruned. The upgrade renames every `coverage.*` change not in this release's
56
+ shape: `subject` and `data.source_id` become the source's new id, `source` and `last_error` are
57
+ removed. A change about a source deleted since is named `src_redacted`: nothing can match it any
58
+ more, and a made-up id would look like a source the tenant could find. A change an earlier
59
+ release's server (0.3-0.5) still running writes after the upgrade is put in the same shape as it
60
+ is inserted, by a trigger on the outbox (`slotlock.outbox_coverage_in_shape`), so none is stored
61
+ with its source. The read path redacts too: `listChanges`, `listRecentChanges`, `listHistory`,
62
+ webhook deliveries and `slotlockCloudEvent` never serve a `coverage.*` change with `source` or
63
+ `last_error`, and name one whose subject is not a `source_id` of this release `src_redacted`;
64
+ history by `subject` never finds a coverage change by any other name, so it cannot confirm a
65
+ guessed source either.
66
+
67
+ ### Changed
68
+
69
+ - **Breaking:** every `source_id` changes, and its shape is `src_` and 32 hex digits (was 24). A
70
+ reader that stored `source_id`s or `subject`s of `coverage.*` changes matches them again from
71
+ `listCoverage` / `GET /v1/resources/{id}/coverage`. The same source recorded for two resources,
72
+ or deleted and recorded again, has a different id each time.
73
+ - **Breaking:** changes already on the feed about coverage are rewritten in place by the upgrade, as
74
+ above; their positions and ids (`webhook-id`) do not change, so a webhook that already received
75
+ one is not sent it again, and one that has not receives the new shape.
76
+
77
+ - **Breaking:** `slotlockCoverageSourceId` throws (`coverage_source_id_unavailable`) instead of
78
+ returning 0.5.3's digest, which nothing carries any more and which would confirm a guessed source:
79
+ read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`) instead.
80
+ - `slotlock migrate` (and `serve --migrate`) run as a role that does not own the schema, with no
81
+ `SLOTLOCK_MIGRATE_DATABASE_URL`, now fail with a first line that says to set
82
+ `SLOTLOCK_MIGRATE_DATABASE_URL` to the schema owner's URL, then the database's refusal.
83
+ - The README no longer opens with the 0.2.0 known issues: they are in
84
+ `docs/release/0.2.0-known-issues.md`, linked under "Supported versions".
85
+ - The hosted service page, its site page and SECURITY.md say the hosted service is not open and
86
+ that self-hosting is the only supported path today; SECURITY.md no longer invites testing it.
87
+
88
+ ### Deprecated
89
+
90
+ - `slotlockCoverageSourceId`, which now only throws. It may be removed in 0.7.0.
91
+
92
+ ### Upgrading
93
+
94
+ - **Breaking:** the schema is version 4. A 0.5 release refuses to migrate it ("Slotlock will not
95
+ migrate a schema a newer release migrated") and its server refuses to serve it, since its DDL would
96
+ put back the trigger that names a source by its guessable digest. Rolling back to 0.5.x needs a
97
+ restore of a backup taken before the upgrade.
98
+ - The migration adds `calendar_coverage.source_id` without rewriting the table (the column bare, its
99
+ random default set apart), gives each existing source an id under the migration's lock
100
+ (`calendar_coverage` is one row per source and resource), and adds its CHECK NOT VALID, validated
101
+ after the migration commits.
102
+ - Changes written from the upgrade on are put in shape as they are inserted. Those written before
103
+ it: once the structure commits, `applySchema` renames them in batches of 5,000 outbox ids by
104
+ primary key, each its own transaction, up to the last change when the pass started, read in a
105
+ short transaction of its own once the outbox writes in flight have ended. Where it got to is saved
106
+ with each batch (`schema_version.coverage_changes_renamed_through`): an interrupted pass goes on
107
+ from there, and the next migration reads only what was written since, until a pass after the
108
+ upgrade's finds nothing to rename and marks it done (`coverage_changes_renamed`). After that no
109
+ migration reads the outbox for it again.
110
+ - Locks: under a deployment role that forced RLS binds, each batch lifts FORCE from
111
+ `calendar_coverage` and `outbox` and puts it back before it commits, holding ACCESS EXCLUSIVE on
112
+ both for that batch (one range of 5,000 ids: milliseconds), so writes to either table wait that
113
+ long, once per batch, for every batch of the outbox on the upgrade and for the rows written since
114
+ on the passes after it. A superuser or BYPASSRLS role holds SHARE ROW EXCLUSIVE on the outbox
115
+ for the short transaction that reads the head (writes wait, reads do not), then only the row
116
+ locks of the rows it renames. `SLOTLOCK_CORE_DDL`, applied with psql, renames in its one transaction instead, with
117
+ the outbox under ACCESS EXCLUSIVE for the whole of it: a scan of the whole outbox on the upgrade.
118
+ - A migration step ended as a deadlock's victim (a caller's transaction that took the outbox, then a
119
+ coverage row, while a rename batch took them in the other order, say) is retried like one that
120
+ waited past its lock timeout, up to `attempts`, instead of failing.
121
+
6
122
  ## 0.5.3 - 2026-10-28
7
123
 
124
+ 0.5.3 shipped breaking changes in a patch release, contrary to [VERSIONING.md](VERSIONING.md): the
125
+ two marked "Breaking" under **Changed**. Breaking changes will be minor releases from now on.
126
+
8
127
  ### Added
9
128
 
10
129
  - Configuration changes on the feed and in history (RFC 0002 P1-5, P1-6), each in the change's own
@@ -93,11 +212,11 @@ versioning.
93
212
 
94
213
  ### Changed
95
214
 
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
215
+ - Breaking: the feed now also records configuration changes, so a reader or webhook with no
216
+ `event_types` filter receives them too; filter by type to keep only bookings.
217
+ - Breaking: `coverage.stale` names its source by `source_id`, as `subject` and in `data`, in
218
+ place of `source`, and no longer carries `last_error`: a source can be an iCalendar URL with a
219
+ token or an address, and an error can quote it. `slotlock_get_coverage` still shows both to the tenant, with the
101
220
  `source_id` to match a change to its source. Changes already on the feed keep the old payload
102
221
  until they are pruned.
103
222
 
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.
@@ -192,10 +154,11 @@ slotlock rules clear --resource vehicle-42
192
154
 
193
155
  With Node.js 22.12+ and PostgreSQL 16 or newer, run each command as `npx slotlock` after
194
156
  `npm install slotlock postgres`, or from a clone, built once, as `node dist/cli.js` (a clone does not
195
- put `slotlock` on your `PATH`). Use two roles, as Compose does:
196
- the server connects as a role that owns nothing and cannot bypass row-level security, and `migrate`
197
- connects as the database owner and grants that role what it needs. `serve` refuses to start as a
198
- 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.
199
162
 
200
163
  ```sh
201
164
  npm ci && npm run build
@@ -228,7 +191,7 @@ slotlock key revoke <id>
228
191
 
229
192
  - A key (`slk_…`) is shown once and stored only as a SHA-256. Send it as `Authorization: Bearer`.
230
193
  - It acts in the tenant it was created for (`SLOTLOCK_TENANT`), so one server serves many tenants.
231
- - `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`
232
195
  covers the [application API](#application-api), which no agent needs. Default: read and write.
233
196
  - `rotate` keeps the key's id, scopes and bookings; `revoke` stops it on its next request.
234
197
  - A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can
@@ -596,6 +559,9 @@ export async function bookHandover(store: SlotlockStore, tenantRef: string) {
596
559
  }
597
560
  ```
598
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
+
599
565
  `withTenant` validates the tenant, sets the context transaction-locally on the one connection every
600
566
  callback call uses, supports nested savepoints, and restores the previous context. Do not issue a
601
567
  standalone `set_config(..., true)` through a pool: its transaction ends before the next call.
@@ -648,6 +614,49 @@ Writing rules:
648
614
  reference for a genuinely new event.
649
615
  - `listCalendarEvents` and `listResources` return at most 1,000 rows and page with keyset cursors.
650
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
+
651
660
  ### Recurring events
652
661
 
653
662
  <!-- example: examples/store.ts#recurring -->
@@ -994,7 +1003,7 @@ A refusal is a result the model can act on:
994
1003
 
995
1004
  | Code | Meaning |
996
1005
  | --- | --- |
997
- | `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 |
998
1007
  | `forbidden` | `authorize` refused the call |
999
1008
  | `rate_limited` | `consumeRateLimit` refused the call |
1000
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` |
@@ -1186,8 +1195,8 @@ waits for a confirmation.
1186
1195
  | `POST /v1/resources/{resource_id}/archive` | Take it out of use; `…/restore` puts it back |
1187
1196
  | `DELETE /v1/resources/{resource_id}` | Delete it with its history (`409 resource_in_use` while anything on it is live) |
1188
1197
  | `GET /v1/resources/{resource_id}/busy?start=…&end=…` | Its occupied time, private resources included |
1189
- | `PUT /v1/resources/{resource_id}/availability` | Its own weekly hours; `[]` never bookable, `null` the server's |
1190
- | `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 |
1191
1200
  | `POST /v1/holds` | Hold a window for `ttl_seconds` (up to 7 days) |
1192
1201
  | `GET /v1/holds/{hold_id}` | One live hold; `GET /v1/resources/{resource_id}/holds` lists them |
1193
1202
  | `POST /v1/holds/{hold_id}/confirm` | Turn the hold into a reservation |
@@ -1253,9 +1262,12 @@ webhook URLs or secrets.
1253
1262
  and check-in rules, pool, bookable hours, time zone or reference, with `before` and `after` of
1254
1263
  only the fields that changed), `resource.archived`, `.restored`, `.deleted`; `coverage.recorded`
1255
1264
  (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;
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`;
1259
1271
  `webhook.created`, `.enabled`, `.disabled`, `.deleted` (the id and `event_types`, never the URL);
1260
1272
  `key.created`, `.rotated`, `.revoked` (the id, scopes and expiry, never the key, its digest,
1261
1273
  prefix or name). `SLOTLOCK_CHANGE_TYPES` lists them.
@@ -1615,13 +1627,15 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1615
1627
  | PostgreSQL | 16, 17 and 18 (CI runs the database suites on each; Compose ships 18) |
1616
1628
  | postgres (peer) | ^3.4.5 |
1617
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
+
1618
1632
  ## Project status and boundaries
1619
1633
 
1620
1634
  Slotlock is availability maths, holds and reservations, events with recurrence, coverage-aware
1621
1635
  free/busy, tenant isolation, iCalendar interchange, MCP and A2A with human confirmation and live
1622
1636
  updates, an MCP Apps view, REST with the application API, scoped API keys, a GitHub sign-in
1623
- dashboard, maintenance, and clients for five languages. [Hosted Slotlock](docs/hosted/README.md),
1624
- 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
+ runs it for you, or you self-host it. Provider sync (Google, Microsoft, CalDAV), notifications to people, and a human
1625
1639
  calendar app are yours to build around it. What each version promises is in
1626
1640
  [VERSIONING.md](VERSIONING.md).
1627
1641
 
package/SECURITY.md CHANGED
@@ -22,19 +22,12 @@ Do not include real customer, credential, calendar, or booking data. We will ack
22
22
  within three business days and coordinate disclosure after a fix is available. Fixed vulnerabilities
23
23
  are published as advisories at <https://github.com/PILOTAAI/slotlock/security/advisories>.
24
24
 
25
- ## Testing hosted Slotlock
25
+ ## Hosted Slotlock
26
26
 
27
- Hosted Slotlock (`slotlock-api.pylota.io` and `app.slotlock.pylota.io`, run by TREFT LTD) is in scope,
28
- under these rules, which its [Terms of Service](TERMS.md) and
29
- [Acceptable Use Policy](ACCEPTABLE-USE.md) refer to:
30
-
31
- - Test only against an account of your own, signed in with your own GitHub account.
32
- - Never read, change or keep another account's data. If a test reaches any, stop and report it at
33
- once, and delete what you saw.
34
- - Run no denial of service, load test or automated scanning that sends more than the published
35
- limits ([hosted service page](docs/hosted/README.md#limits)); run Slotlock yourself for that.
36
- - Report through private vulnerability reporting as above, and give us the time to fix it before
37
- you disclose anything.
27
+ The hosted service is not open; self-hosting is the only supported path today. Do not test
28
+ `slotlock-api.pylota.io` or `app.slotlock.pylota.io`: they are not in scope until this section
29
+ says the service is open and gives the rules for testing it. Test Slotlock by running it yourself,
30
+ and report what you find as above.
38
31
 
39
32
  ## Security model
40
33
 
@@ -84,7 +77,10 @@ earlier, `PUBLIC` may still hold `CREATE` on `public`; the check then refuses ev
84
77
  a lock that blocks other tenants: that is availability, not isolation. `slotlock serve` checks the
85
78
  role it connects as before it serves, and refuses to start as a superuser or a `BYPASSRLS` role
86
79
  (`unsafe_serving_role`); it does not repeat the rest of this check, so that a single owning role can
87
- still migrate and serve (`serve --migrate` without `SLOTLOCK_MIGRATE_DATABASE_URL`).
80
+ still migrate and serve (`serve --migrate` without `SLOTLOCK_MIGRATE_DATABASE_URL`). Row-level
81
+ security does not confine the owner on tables that do not force it, and the owner can lift it, so
82
+ `serve` logs a `serving_as_schema_owner` warning at startup when its role owns the `slotlock`
83
+ schema: serve as a role that owns nothing in production.
88
84
 
89
85
  Schema changes run under a separate deployment role, which must own the `slotlock` schema and every
90
86
  object in it (`applySchema` creates the schema when it is missing). The deployment writes rows into
@@ -105,6 +101,23 @@ as the deployment role or a superuser, or trust its owner as much. `SLOTLOCK_COR
105
101
  caller's `search_path` at their end; apply each in one transaction (`psql --single-transaction`),
106
102
  since under autocommit the pin ends with the statement that sets it.
107
103
 
104
+ The change feed, the history and webhooks name a coverage source by its `source_id`, never the
105
+ source itself, which can be an iCalendar URL with a token or an address. The id is random (`src_` and
106
+ 32 hex digits from `gen_random_uuid()`), stored with the source when it is first recorded and kept
107
+ until it is deleted, so someone who sees a change and knows the tenant cannot confirm a guessed
108
+ source against it (0.5.3's id was an unkeyed digest of the tenant and the source, and could be).
109
+ The tenant reads the id beside the source from `listCoverage`. Upgrading renames the coverage
110
+ changes earlier releases wrote, which carried the source, its last error or that digest, and a
111
+ trigger on the outbox puts one an earlier release's server (0.3-0.5) still running writes after the
112
+ upgrade in the same shape as it is inserted, so none is stored with its source. Every read path serves a `coverage.*` change without `source` and `last_error`, named
113
+ `src_redacted` when its subject is not such an id, and history by subject never matches a coverage
114
+ change by any other name. What was already sent stays sent: a webhook receiver keeps an earlier
115
+ delivery, so rotate a token that an earlier release delivered in a `coverage.stale` change. A
116
+ 0.5.3 `source_id` already forwarded (a webhook delivery, a feed reader's copy, a log) still reveals
117
+ its source to anyone who guesses the source and knows the tenant, as it did then; the rename changes
118
+ only the copies in the outbox. `slotlockCoverageSourceId`, which worked that digest out, throws
119
+ since 0.6.
120
+
108
121
  The privacy-minimised provider parser cannot return summaries, locations, notes, organizers or
109
122
  attendees. Full event parsing is a separate trusted API and must only receive authenticated content.
110
123
  Event command rows retain payload digests and stable results, never duplicate event content. Event
package/agent-server.d.ts CHANGED
@@ -374,6 +374,12 @@ export interface SlotlockAgentOperationDispatchOptions {
374
374
  /** As `SlotlockAgentServerOptions.onError`: a failure the result reports only as internal. */
375
375
  onError?: (error: unknown, context: SlotlockAgentErrorContext) => void;
376
376
  }
377
+ /** One reason `invalid_arguments` refused a call: where in the arguments, and what is wrong there. */
378
+ export interface SlotlockArgumentIssue {
379
+ /** The field's path in the arguments, outermost first (`["rules", 0, "rrule"]`); `[]` is the arguments as a whole. */
380
+ path: (string | number)[];
381
+ message: string;
382
+ }
377
383
  export type SlotlockAgentOperationDispatchResult = {
378
384
  ok: true;
379
385
  data: Record<string, unknown>;
@@ -382,6 +388,8 @@ export type SlotlockAgentOperationDispatchResult = {
382
388
  status: number;
383
389
  code: string;
384
390
  details?: Readonly<Record<string, string>>;
391
+ /** With `invalid_arguments` from argument validation: the fields it refused. */
392
+ issues?: readonly SlotlockArgumentIssue[];
385
393
  };
386
394
  /** Runtime type guard for the current operation names (legacy dotted names are not included). */
387
395
  export declare function isSlotlockAgentOperation(name: unknown): name is SlotlockAgentOperation;
@@ -419,6 +427,7 @@ export declare function slotlockMcpToolResult(outcome: {
419
427
  ok: false;
420
428
  code: string;
421
429
  details?: Readonly<Record<string, string | number | boolean | null>>;
430
+ issues?: readonly SlotlockArgumentIssue[];
422
431
  }): SlotlockMcpToolResult;
423
432
  /** The application operations with their input and output as JSON Schema, as REST serves them. */
424
433
  export declare function slotlockApplicationOperations(): {
package/agent-server.js CHANGED
@@ -380,6 +380,21 @@ export class SlotlockAgentOperationError extends Error {
380
380
  this.details = Object.freeze(Object.fromEntries(kept));
381
381
  }
382
382
  }
383
+ /** At most this many issues are returned, each message cut to {@link MAX_ISSUE_MESSAGE} characters. */
384
+ const MAX_ISSUES = 20;
385
+ const MAX_ISSUE_MESSAGE = 200;
386
+ /** Argument validation's refusal: 400 `invalid_arguments` with the fields zod refused. */
387
+ function invalidArguments(error) {
388
+ return {
389
+ ok: false,
390
+ status: 400,
391
+ code: 'invalid_arguments',
392
+ issues: error.issues.slice(0, MAX_ISSUES).map((issue) => ({
393
+ path: issue.path.map((key) => (typeof key === 'number' ? key : String(key))),
394
+ message: issue.message.slice(0, MAX_ISSUE_MESSAGE),
395
+ })),
396
+ };
397
+ }
383
398
  const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
384
399
  const EXAMPLE_EVENT_ID = 'agent:5e8ff9bf55ba3508199d22e984129be6b5b6a8a7e3e5f1f0d2b9ba0c4a6f7e21';
385
400
  const EXAMPLE_WINDOW = { start: '2027-03-01T00:00:00Z', end: '2027-03-08T00:00:00Z' };
@@ -845,6 +860,8 @@ export function slotlockMcpToolResult(outcome) {
845
860
  if (key !== 'code')
846
861
  error[key] = value;
847
862
  }
863
+ if (outcome.issues)
864
+ error.issues = outcome.issues;
848
865
  return { content: [{ type: 'text', text: JSON.stringify({ error }) }], isError: true };
849
866
  }
850
867
  /** The agent tools as REST serves them: each with the scope it needs. */
@@ -1152,7 +1169,7 @@ export async function invokeSlotlockAgentOperation(args) {
1152
1169
  }
1153
1170
  const parsed = operation.input.safeParse(args.input ?? {});
1154
1171
  if (!parsed.success)
1155
- return { ok: false, status: 400, code: 'invalid_arguments' };
1172
+ return invalidArguments(parsed.error);
1156
1173
  if (!(await args.options.authorize({
1157
1174
  principal,
1158
1175
  operation: operation.name,
@@ -3145,7 +3162,7 @@ export function createSlotlockAgentServer(options) {
3145
3162
  }
3146
3163
  const parsed = definition.input.safeParse(given);
3147
3164
  if (!parsed.success)
3148
- return { ok: false, status: 400, code: 'invalid_arguments' };
3165
+ return invalidArguments(parsed.error);
3149
3166
  if (!(await served.authorize({ principal, operation: definition.name, input: parsed.data }))) {
3150
3167
  return { ok: false, status: 403, code: 'forbidden' };
3151
3168
  }
@@ -3309,6 +3326,7 @@ export function createSlotlockAgentServer(options) {
3309
3326
  return refuse(status, outcome.code, {}, {
3310
3327
  ...(outcome.code === 'overlap' ? refusedInterval(given) : {}),
3311
3328
  ...outcome.details,
3329
+ ...(outcome.issues ? { issues: outcome.issues } : {}),
3312
3330
  });
3313
3331
  }
3314
3332
  return {
@@ -3685,7 +3703,15 @@ export function createSlotlockAgentServer(options) {
3685
3703
  if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
3686
3704
  return json(200, rpcError(id, -32603, 'Internal error'));
3687
3705
  }
3688
- return reply(outcome.ok ? outcome.data : { error: { ...outcome.details, code: outcome.code } });
3706
+ return reply(outcome.ok
3707
+ ? outcome.data
3708
+ : {
3709
+ error: {
3710
+ ...outcome.details,
3711
+ code: outcome.code,
3712
+ ...(outcome.issues ? { issues: outcome.issues } : {}),
3713
+ },
3714
+ });
3689
3715
  }
3690
3716
  catch {
3691
3717
  return json(200, rpcError(id, -32603, 'Internal error'));
@@ -757,7 +757,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
757
757
  {
758
758
  name: 'slotlock_get_coverage',
759
759
  title: "A resource's coverage and its health",
760
- description: "Each external calendar (source) that records coverage of a resource: the window it covers, when it was last read, its last error, and whether it is stale (not read for a day, so the time it covers is unproven, not free). A source that goes stale is also put on the change feed once, as coverage.stale, until it is read again. The feed names a source by its source_id, never the source itself.",
760
+ description: "Each external calendar (source) that records coverage of a resource: the window it covers, when it was last read, its last error, and whether it is stale (not read for a day, so the time it covers is unproven, not free). A source that goes stale is also put on the change feed once, as coverage.stale, until it is read again. The feed names a source by its source_id (random, made when the source is first recorded for the resource and kept until it is deleted), never the source itself.",
761
761
  method: 'GET',
762
762
  path: '/v1/resources/{resource_id}/coverage',
763
763
  input: z.object({ resource_id: RESOURCE_ID }).strict(),
@@ -767,8 +767,11 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
767
767
  .array(z
768
768
  .object({
769
769
  source: SOURCE,
770
- /** How the change feed names the source (coverage.recorded, .deleted, .stale). */
771
- source_id: z.string().regex(/^src_[0-9a-f]{24}$/),
770
+ /**
771
+ * How the change feed names the source (coverage.recorded, .deleted, .stale): random,
772
+ * made when it is first recorded for the resource, kept until it is deleted.
773
+ */
774
+ source_id: z.string().regex(/^src_[0-9a-f]{32}$/),
772
775
  start: DATE_TIME,
773
776
  end: DATE_TIME,
774
777
  revision: REVISION,