slotlock 0.2.0 → 0.5.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/agent-server.js CHANGED
@@ -3,9 +3,10 @@ import { z } from 'zod';
3
3
  import { SLOTLOCK_MCP_APPS_EXTENSION, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, clientSupportsSlotlockMcpApp, } from './mcp-app.js';
4
4
  import { MCP_META_PROTOCOL_VERSION, MCP_META_SERVER_INFO, MCP_META_SUBSCRIPTION_ID, MCP_MISSING_REQUIRED_CLIENT_CAPABILITY, canonicalJson, checkModernMcpRequest, isRecord, openMcpEventStream, openRequestState, principalTag, sealRequestState, sha256Hex, stateSecrets, traceContextFrom, } from './mcp-modern.js';
5
5
  import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, slotlockApplicationOperation, } from './application-api.js';
6
+ import { SLOTLOCK_EVENT_OUTPUT as EVENT } from './event-schema.js';
6
7
  import { ignoreRejection } from './hooks.js';
7
8
  import { slotlockPackageVersion } from './package-version.js';
8
- import { SLOTLOCK_APPLICATION_REST_ROUTES, SLOTLOCK_REST_ROUTES, matchSlotlockRestRoute, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
9
+ import { SLOTLOCK_APPLICATION_REST_ROUTES, SLOTLOCK_REST_ROUTES, matchSlotlockRestRoute, parseIdempotencyKeyHeader, SLOTLOCK_PROBLEM_DOCS, slotlockProblem, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
9
10
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, calendarEventRollingHorizon, } from './store.js';
10
11
  import { canonicalRecurrenceRule, expandCalendarEventOccurrences, isBookableRecurrenceRule, } from './sync.js';
11
12
  import { zonedOffsetMinutes, zonedWallTime } from './timezone.js';
@@ -59,17 +60,163 @@ export const SLOTLOCK_A2A_TASK_STATES = Object.freeze([
59
60
  const DATE_TIME = z.string().datetime({ offset: true });
60
61
  const RESOURCE_ID = z.string().trim().min(1).max(200);
61
62
  const EVENT_ID = z.string().trim().min(1).max(500);
63
+ const HOLD_ID = z.string().uuid();
64
+ const POOL = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/);
62
65
  const IDEMPOTENCY_KEY = z.string().trim().min(1).max(200);
63
66
  const REVISION = z.number().int().positive();
64
- // Output bounds mirror the trusted store's byte ceilings. A value admitted by a UTF-8 byte
65
- // ceiling can never exceed that many UTF-16 code units, so every valid stored/provider event is
66
- // serializable without truncating recurrence semantics or calendar identities.
67
- const TRUSTED_SUMMARY_OUTPUT_MAX = 4_096;
68
- const TRUSTED_TEXT_OUTPUT_MAX = 16_384;
69
- const TRUSTED_PERSON_NAME_OUTPUT_MAX = 1_024;
70
- const TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX = 1_000;
71
- const TRUSTED_REMINDER_MINUTES_OUTPUT_MAX = 366 * 24 * 60;
72
67
  const MAX_AGENT_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60 * 60 * 1_000;
68
+ /** How many slots `slotlock_find_available_slots` lists at most, and over how many resources. */
69
+ const MAX_LISTED_SLOTS = 50;
70
+ const MAX_SLOT_RESOURCES = 20;
71
+ /** `slotlock_hold_slots`, from a backend that holds; one without it answers `holds_unavailable`. */
72
+ function holdSlots(backend, context, input) {
73
+ if (!backend.holdSlots)
74
+ throw new SlotlockAgentOperationError('holds_unavailable', 409);
75
+ return backend.holdSlots(context, input);
76
+ }
77
+ /** `slotlock_book_pool`, from a backend that books pools, as `holdSlots`. */
78
+ function bookPool(backend, context, input) {
79
+ if (!backend.bookPool)
80
+ throw new SlotlockAgentOperationError('pools_unavailable', 409);
81
+ return backend.bookPool(context, input);
82
+ }
83
+ /** `slotlock_get_pool_availability`, from a backend that knows pools, as `holdSlots`. */
84
+ function getPoolAvailability(backend, context, input) {
85
+ if (!backend.getPoolAvailability)
86
+ throw new SlotlockAgentOperationError('pools_unavailable', 409);
87
+ return backend.getPoolAvailability(context, input);
88
+ }
89
+ /** `slotlock_book_together`, from a backend that books bundles, as `holdSlots`. */
90
+ function bookTogether(backend, context, input) {
91
+ if (!backend.bookTogether)
92
+ throw new SlotlockAgentOperationError('bundles_unavailable', 409);
93
+ return backend.bookTogether(context, input);
94
+ }
95
+ /** `slotlock_release_slots`, as `holdSlots`. */
96
+ function releaseSlots(backend, context, input) {
97
+ if (!backend.releaseSlots)
98
+ throw new SlotlockAgentOperationError('holds_unavailable', 409);
99
+ return backend.releaseSlots(context, input);
100
+ }
101
+ /** Next-available searches one collective search may make, across its resources. */
102
+ const MAX_TOGETHER_SEARCHES = 500;
103
+ /**
104
+ * Times when at least `needed` of the resources are free together for the duration, earliest
105
+ * first. Each resource's next certain start at or after a candidate time comes from the backend's
106
+ * own search (hours, booking rules, coverage, as a single answer judges them); the candidate is
107
+ * the `needed`-th earliest of those starts, and it is a slot when that many start there. Otherwise
108
+ * the search moves on to it, where the resources that start earlier are asked again. A resource's
109
+ * start stays valid until the candidate passes it, so most are asked once per slot at most.
110
+ */
111
+ async function freeTogether(resourceIds, needed, next, options) {
112
+ if (needed > resourceIds.length)
113
+ throw new SlotlockAgentOperationError('invalid_arguments', 400);
114
+ const known = new Map();
115
+ const slots = [];
116
+ let from = options.window.start.getTime();
117
+ let searches = 0;
118
+ while (slots.length < options.limit && from + options.durationMs <= options.window.end.getTime()) {
119
+ for (const resourceId of resourceIds) {
120
+ const start = known.get(resourceId);
121
+ if (start === null || (start !== undefined && start >= from))
122
+ continue;
123
+ if (++searches > MAX_TOGETHER_SEARCHES)
124
+ return slots;
125
+ const found = await next(resourceId, new Date(from).toISOString());
126
+ if (found === 'uncertain')
127
+ return 'uncertain';
128
+ known.set(resourceId, found ? Date.parse(found.start) : null);
129
+ }
130
+ const starts = resourceIds
131
+ .map((resourceId) => known.get(resourceId))
132
+ .filter((start) => typeof start === 'number')
133
+ .sort((a, b) => a - b);
134
+ const candidate = starts[needed - 1];
135
+ if (candidate === undefined)
136
+ return slots;
137
+ const free = resourceIds.filter((resourceId) => known.get(resourceId) === candidate);
138
+ if (free.length >= needed) {
139
+ slots.push({
140
+ resource_id: free[0],
141
+ start: new Date(candidate).toISOString(),
142
+ end: new Date(candidate + options.durationMs).toISOString(),
143
+ resource_ids: free,
144
+ });
145
+ from = candidate + options.durationMs;
146
+ }
147
+ else {
148
+ from = candidate;
149
+ }
150
+ }
151
+ return slots;
152
+ }
153
+ /**
154
+ * Slots for `slotlock_find_available_slots`, from the backend's own next-available search, so each
155
+ * one is checked against busy time, the resource's hours and coverage exactly as a single answer
156
+ * is. Each resource's earliest slot first, then the next one after whichever slot was listed: at
157
+ * most one search per resource plus one per listed slot. Like a single answer, the list is certain
158
+ * only when every resource's coverage is, and empty otherwise.
159
+ */
160
+ async function findAvailableSlots(backend, context, input) {
161
+ const end = input.end;
162
+ const durationMs = input.duration_minutes * 60_000;
163
+ const limit = input.limit;
164
+ const window = { start: new Date(input.start), end: new Date(end) };
165
+ const coverage = (certain) => ({
166
+ start: window.start.toISOString(),
167
+ end: window.end.toISOString(),
168
+ certainty: certain ? 'certain' : 'uncertain',
169
+ reason: certain ? null : 'coverage_incomplete',
170
+ });
171
+ const next = async (resourceId, from) => {
172
+ const found = await backend.findNextAvailable(context, {
173
+ resource_ids: [resourceId],
174
+ start: from,
175
+ end,
176
+ duration_minutes: input.duration_minutes,
177
+ });
178
+ if (found.coverage?.certainty !== 'certain') {
179
+ return 'uncertain';
180
+ }
181
+ return typeof found.start === 'string' && typeof found.end === 'string'
182
+ ? { resource_id: resourceId, start: found.start, end: found.end }
183
+ : null;
184
+ };
185
+ const resourceIds = [...new Set(input.resource_ids)];
186
+ if (typeof input.require_free === 'number') {
187
+ const together = await freeTogether(resourceIds, input.require_free, next, {
188
+ window,
189
+ durationMs,
190
+ limit,
191
+ });
192
+ return together === 'uncertain'
193
+ ? { slots: [], coverage: coverage(false) }
194
+ : { slots: together, coverage: coverage(true) };
195
+ }
196
+ const earliest = (a, b) => Date.parse(a.start) - Date.parse(b.start) || a.resource_id.localeCompare(b.resource_id);
197
+ const heads = [];
198
+ for (const resourceId of resourceIds) {
199
+ const head = await next(resourceId, window.start.toISOString());
200
+ if (head === 'uncertain')
201
+ return { slots: [], coverage: coverage(false) };
202
+ if (head)
203
+ heads.push(head);
204
+ }
205
+ const slots = [];
206
+ while (slots.length < limit && heads.length > 0) {
207
+ heads.sort(earliest);
208
+ const listed = heads.shift();
209
+ slots.push(listed);
210
+ if (slots.length === limit || window.end.getTime() - Date.parse(listed.end) < durationMs)
211
+ continue;
212
+ const after = await next(listed.resource_id, listed.end);
213
+ if (after === 'uncertain')
214
+ return { slots: [], coverage: coverage(false) };
215
+ if (after)
216
+ heads.push(after);
217
+ }
218
+ return { slots, coverage: coverage(true) };
219
+ }
73
220
  /** The longest slot `slotlock_find_next_available` can look for: the whole search window. */
74
221
  const MAX_SLOT_MINUTES = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60;
75
222
  const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * 24 * 60 * 60 * 1_000;
@@ -95,41 +242,6 @@ const COVERAGE = z
95
242
  reason: z.string().max(100).nullable(),
96
243
  })
97
244
  .strict();
98
- const ORGANIZER = z
99
- .object({
100
- address: z.string().email().max(320),
101
- name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
102
- })
103
- .strict();
104
- const ATTENDEE = z
105
- .object({
106
- address: z.string().email().max(320),
107
- name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
108
- role: z.enum(['chair', 'required', 'optional', 'non_participant']),
109
- participation_status: z.enum([
110
- 'needs_action',
111
- 'accepted',
112
- 'declined',
113
- 'tentative',
114
- 'delegated',
115
- ]),
116
- rsvp: z.boolean(),
117
- })
118
- .strict();
119
- const REMINDER = z
120
- .object({
121
- minutes_before: z.number().int().min(0).max(TRUSTED_REMINDER_MINUTES_OUTPUT_MAX),
122
- channel: z.enum(['display', 'email']),
123
- })
124
- .strict();
125
- const RECURRENCE_EXCEPTION = z
126
- .object({
127
- recurrence_id: DATE_TIME,
128
- cancelled: z.boolean(),
129
- starts_at: DATE_TIME.nullable(),
130
- ends_at: DATE_TIME.nullable(),
131
- })
132
- .strict();
133
245
  const ORGANIZER_INPUT = z
134
246
  .object({
135
247
  address: z.string().email().max(320),
@@ -187,30 +299,6 @@ const MCP_INITIALIZE_PARAMS = z
187
299
  _meta: z.record(z.string(), z.unknown()).optional(),
188
300
  })
189
301
  .passthrough();
190
- const EVENT = z
191
- .object({
192
- id: EVENT_ID,
193
- resource_id: RESOURCE_ID,
194
- starts_at: DATE_TIME,
195
- ends_at: DATE_TIME,
196
- timezone: z.string().min(1).max(100),
197
- title: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
198
- description: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
199
- location: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
200
- organizer: ORGANIZER.nullable(),
201
- attendees: z.array(ATTENDEE).max(100),
202
- reminders: z.array(REMINDER).max(20),
203
- status: z.enum(['tentative', 'confirmed', 'cancelled']),
204
- transparency: z.enum(['opaque', 'transparent']),
205
- sequence: z.number().int().nonnegative(),
206
- revision: REVISION,
207
- recurrence_rule: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
208
- recurrence_exceptions: z
209
- .array(RECURRENCE_EXCEPTION)
210
- .max(TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX),
211
- recurrence_id: DATE_TIME.nullable(),
212
- })
213
- .strict();
214
302
  /**
215
303
  * Names earlier builds published, which every entry point still accepts and dispatches as the same
216
304
  * operation; nothing advertises them. The dotted names came first, before tool names had to be
@@ -243,6 +331,12 @@ const OPERATION_SCOPES = Object.freeze({
243
331
  slotlock_list_resources: 'read',
244
332
  slotlock_get_free_busy: 'read',
245
333
  slotlock_find_next_available: 'read',
334
+ slotlock_find_available_slots: 'read',
335
+ slotlock_hold_slots: 'write',
336
+ slotlock_book_together: 'write',
337
+ slotlock_book_pool: 'write',
338
+ slotlock_get_pool_availability: 'read',
339
+ slotlock_release_slots: 'write',
246
340
  slotlock_create_event: 'write',
247
341
  slotlock_get_event: 'read',
248
342
  slotlock_list_events: 'read',
@@ -263,11 +357,25 @@ function principalScopesCover(principal, operation) {
263
357
  export class SlotlockAgentOperationError extends Error {
264
358
  code;
265
359
  status;
266
- constructor(code, status) {
360
+ /**
361
+ * What the refusal is about, beside its code (the resource a bundle was refused on, say): at most
362
+ * five snake_case names with text values of up to 200 characters; anything else is dropped. Every
363
+ * protocol carries them next to `code`.
364
+ */
365
+ details;
366
+ constructor(code, status, details) {
267
367
  super(code);
268
368
  this.name = 'SlotlockAgentOperationError';
269
369
  this.code = /^[a-z][a-z0-9_]{0,63}$/.test(code) ? code : 'operation_failed';
270
- this.status = [400, 403, 404, 409, 429, 503].includes(status) ? status : 500;
370
+ this.status = [400, 403, 404, 409, 410, 429, 503].includes(status) ? status : 500;
371
+ const kept = Object.entries(details ?? {})
372
+ .filter(([name, value]) => /^[a-z][a-z0-9_]{0,39}$/.test(name) &&
373
+ name !== 'code' &&
374
+ typeof value === 'string' &&
375
+ value.length <= 200)
376
+ .slice(0, 5);
377
+ if (kept.length > 0)
378
+ this.details = Object.freeze(Object.fromEntries(kept));
271
379
  }
272
380
  }
273
381
  const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
@@ -290,6 +398,8 @@ const EVENT_CREATE_INPUT = z
290
398
  reminders: z.array(REMINDER_INPUT).max(20).optional(),
291
399
  idempotency_key: IDEMPOTENCY_KEY,
292
400
  recurrence_exceptions: z.array(RECURRENCE_EXCEPTION_INPUT).max(500).optional(),
401
+ /** Book the time one of your holds keeps (slotlock_hold_slots); its alternatives are released. */
402
+ hold_id: HOLD_ID.optional(),
293
403
  })
294
404
  .strict()
295
405
  .refine((value) => boundedEventSpan(value.starts_at, value.ends_at));
@@ -297,11 +407,12 @@ const OPERATION_DEFINITIONS = [
297
407
  {
298
408
  name: 'slotlock_list_resources',
299
409
  title: 'List calendar resources',
300
- description: 'List resources visible to the authenticated tenant principal.',
410
+ description: 'List resources visible to the authenticated tenant principal, with the pool each belongs to (a vehicle class, a room type). With pool, only that pool\'s members, in the order slotlock_book_pool tries them.',
301
411
  input: z
302
412
  .object({
303
413
  cursor: z.string().max(500).optional(),
304
414
  limit: z.number().int().min(1).max(100).default(50),
415
+ pool: POOL.optional(),
305
416
  })
306
417
  .strict(),
307
418
  output: z
@@ -312,6 +423,7 @@ const OPERATION_DEFINITIONS = [
312
423
  id: RESOURCE_ID,
313
424
  external_ref: z.string().max(500).nullable(),
314
425
  timezone: z.string().min(1).max(100),
426
+ pool: POOL.optional(),
315
427
  })
316
428
  .strict())
317
429
  .max(100),
@@ -376,6 +488,40 @@ const OPERATION_DEFINITIONS = [
376
488
  invoke: 'findNextAvailable',
377
489
  example: { resource_ids: [EXAMPLE_RESOURCE_ID], ...EXAMPLE_WINDOW, duration_minutes: 120 },
378
490
  },
491
+ {
492
+ name: 'slotlock_find_available_slots',
493
+ title: 'Find available slots',
494
+ description: 'List up to `limit` certain slots of the requested duration, earliest first across the resources, each one free of busy time and inside the resource\'s hours, to offer a person a choice. With require_free, only times when at least that many of the resources are free together, each slot naming them in resource_ids: every resource for a booking that needs them all (book it with slotlock_book_together), or any n of them. No slot is listed unless every resource\'s coverage is certain.',
495
+ input: z
496
+ .object({
497
+ resource_ids: z.array(RESOURCE_ID).min(1).max(MAX_SLOT_RESOURCES),
498
+ start: DATE_TIME,
499
+ end: DATE_TIME,
500
+ duration_minutes: z.number().int().min(1).max(MAX_SLOT_MINUTES),
501
+ limit: z.number().int().min(1).max(MAX_LISTED_SLOTS).default(10),
502
+ require_free: z.number().int().min(1).max(MAX_SLOT_RESOURCES).optional(),
503
+ })
504
+ .strict()
505
+ .refine(boundedAgentWindow),
506
+ output: z
507
+ .object({
508
+ slots: z
509
+ .array(z
510
+ .object({
511
+ resource_id: RESOURCE_ID,
512
+ start: DATE_TIME,
513
+ end: DATE_TIME,
514
+ resource_ids: z.array(RESOURCE_ID).min(1).max(MAX_SLOT_RESOURCES).optional(),
515
+ })
516
+ .strict())
517
+ .max(MAX_LISTED_SLOTS),
518
+ coverage: COVERAGE,
519
+ })
520
+ .strict(),
521
+ risk: { readOnly: true, destructive: false, idempotent: true },
522
+ invoke: findAvailableSlots,
523
+ example: { resource_ids: [EXAMPLE_RESOURCE_ID], ...EXAMPLE_WINDOW, duration_minutes: 60, limit: 10 },
524
+ },
379
525
  {
380
526
  name: 'slotlock_create_event',
381
527
  title: 'Create resource event',
@@ -393,6 +539,86 @@ const OPERATION_DEFINITIONS = [
393
539
  idempotency_key: 'create-handover-2027-03-02',
394
540
  },
395
541
  },
542
+ {
543
+ name: 'slotlock_book_pool',
544
+ title: 'Book from a pool',
545
+ description: 'Book any one member of a pool (a vehicle class, a room type) for a time: members are tried in a fixed order and the first that is free and allows the booking is booked, in one transaction, so a class sells out exactly at its number of members. The event names the member booked. 409 pool_full when none is free; 404 pool_not_found when the pool has no member you may book. A retry with the same idempotency_key answers the same event.',
546
+ input: z
547
+ .object({
548
+ pool: POOL,
549
+ starts_at: DATE_TIME,
550
+ ends_at: DATE_TIME,
551
+ timezone: z.string().min(1).max(100),
552
+ title: z.string().trim().min(1).max(500).optional(),
553
+ idempotency_key: IDEMPOTENCY_KEY,
554
+ })
555
+ .strict()
556
+ .refine((value) => boundedEventSpan(value.starts_at, value.ends_at)),
557
+ output: z.object({ event: EVENT, replayed: z.boolean() }).strict(),
558
+ risk: { readOnly: false, destructive: false, idempotent: true },
559
+ invoke: bookPool,
560
+ example: {
561
+ pool: 'van',
562
+ starts_at: '2027-03-02T09:00:00Z',
563
+ ends_at: '2027-03-03T09:00:00Z',
564
+ timezone: 'Europe/London',
565
+ title: 'Van hire',
566
+ idempotency_key: 'van-hire-2027-03-02',
567
+ },
568
+ },
569
+ {
570
+ name: 'slotlock_get_pool_availability',
571
+ title: 'Get pool availability',
572
+ description: 'How many members of a pool (a vehicle class, a room type) are free across a window, each only where its booking rules let a booking start: the window split where that number changes, each part with how many are free, and coverage certainty. Book one with slotlock_book_pool. 404 pool_not_found when the pool has no member you may book.',
573
+ input: z
574
+ .object({ pool: POOL, start: DATE_TIME, end: DATE_TIME })
575
+ .strict()
576
+ .refine(boundedAgentWindow),
577
+ output: z
578
+ .object({
579
+ pool: POOL,
580
+ members: z.number().int().min(1).max(100),
581
+ intervals: z
582
+ .array(z
583
+ .object({ start: DATE_TIME, end: DATE_TIME, free: z.number().int().min(0).max(100) })
584
+ .strict())
585
+ .max(20_001),
586
+ coverage: COVERAGE,
587
+ })
588
+ .strict(),
589
+ risk: { readOnly: true, destructive: false, idempotent: true },
590
+ invoke: getPoolAvailability,
591
+ example: { pool: 'van', ...EXAMPLE_WINDOW },
592
+ },
593
+ {
594
+ name: 'slotlock_book_together',
595
+ title: 'Book resources together',
596
+ description: 'Book the same time on 2-20 resources at once, all or none: a van with its driver, a room with its projector. If any of them is taken, nothing is booked, and the refusal names that resource_id. Find such a time with slotlock_find_available_slots and require_free. A retry with the same idempotency_key answers the same events.',
597
+ input: z
598
+ .object({
599
+ resource_ids: z.array(RESOURCE_ID).min(2).max(MAX_SLOT_RESOURCES),
600
+ starts_at: DATE_TIME,
601
+ ends_at: DATE_TIME,
602
+ timezone: z.string().min(1).max(100),
603
+ title: z.string().trim().min(1).max(500).optional(),
604
+ idempotency_key: IDEMPOTENCY_KEY,
605
+ })
606
+ .strict()
607
+ .refine((value) => boundedEventSpan(value.starts_at, value.ends_at)),
608
+ output: z
609
+ .object({ events: z.array(EVENT).min(2).max(MAX_SLOT_RESOURCES), replayed: z.boolean() })
610
+ .strict(),
611
+ risk: { readOnly: false, destructive: false, idempotent: true },
612
+ invoke: bookTogether,
613
+ example: {
614
+ resource_ids: [EXAMPLE_RESOURCE_ID, '1f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b'],
615
+ starts_at: '2027-03-02T09:00:00Z',
616
+ ends_at: '2027-03-02T10:00:00Z',
617
+ timezone: 'Europe/London',
618
+ title: 'Handover with driver',
619
+ idempotency_key: 'bundle-handover-2027-03-02',
620
+ },
621
+ },
396
622
  {
397
623
  name: 'slotlock_get_event',
398
624
  title: 'Get resource event',
@@ -427,7 +653,7 @@ const OPERATION_DEFINITIONS = [
427
653
  {
428
654
  name: 'slotlock_update_event',
429
655
  title: 'Update resource event',
430
- description: 'Apply an expected-revision event patch and rematerialize occupancy atomically.',
656
+ description: 'Apply an expected-revision event patch and rematerialize occupancy atomically. With expected_resource_id, the resource you read the event on, an event since moved elsewhere is not found.',
431
657
  input: z
432
658
  .object({
433
659
  event_id: EVENT_ID,
@@ -447,6 +673,8 @@ const OPERATION_DEFINITIONS = [
447
673
  recurrence_rule: RECURRENCE_RULE.nullable().optional(),
448
674
  recurrence_exceptions: z.array(RECURRENCE_EXCEPTION_INPUT).max(500).optional(),
449
675
  idempotency_key: IDEMPOTENCY_KEY,
676
+ // Last, so the generated clients that take optional arguments by position keep their order.
677
+ expected_resource_id: RESOURCE_ID.optional(),
450
678
  })
451
679
  .strict()
452
680
  .refine((value) => value.starts_at !== undefined ||
@@ -504,8 +732,65 @@ const OPERATION_DEFINITIONS = [
504
732
  idempotency_key: 'cancel-handover-2027-03-02',
505
733
  },
506
734
  },
735
+ {
736
+ name: 'slotlock_hold_slots',
737
+ title: 'Hold candidate slots',
738
+ description: 'Hold up to five candidate slots for a short time (30-900 seconds) while a person chooses, as alternatives in one group: each free one is held, the others are listed with why not. Book one by passing its hold_id to slotlock_create_event, which releases the rest; unbooked holds expire on their own. A hold is yours alone, and you may have 20 live at once.',
739
+ input: z
740
+ .object({
741
+ slots: z
742
+ .array(z
743
+ .object({ resource_id: RESOURCE_ID, start: DATE_TIME, end: DATE_TIME })
744
+ .strict()
745
+ .refine((value) => boundedEventSpan(value.start, value.end)))
746
+ .min(1)
747
+ .max(5),
748
+ ttl_seconds: z.number().int().min(30).max(900).default(300),
749
+ idempotency_key: IDEMPOTENCY_KEY,
750
+ })
751
+ .strict(),
752
+ output: z
753
+ .object({
754
+ group_id: HOLD_ID,
755
+ holds: z
756
+ .array(z
757
+ .object({
758
+ hold_id: HOLD_ID,
759
+ resource_id: RESOURCE_ID,
760
+ start: DATE_TIME,
761
+ end: DATE_TIME,
762
+ expires_at: DATE_TIME,
763
+ })
764
+ .strict())
765
+ .max(5),
766
+ refused: z
767
+ .array(z.object({ index: z.number().int().min(0).max(4), code: z.string().max(64) }).strict())
768
+ .max(5),
769
+ })
770
+ .strict(),
771
+ risk: { readOnly: false, destructive: false, idempotent: true },
772
+ invoke: holdSlots,
773
+ example: {
774
+ slots: [
775
+ { resource_id: EXAMPLE_RESOURCE_ID, start: '2027-03-02T09:00:00Z', end: '2027-03-02T10:00:00Z' },
776
+ { resource_id: EXAMPLE_RESOURCE_ID, start: '2027-03-02T14:00:00Z', end: '2027-03-02T15:00:00Z' },
777
+ ],
778
+ ttl_seconds: 300,
779
+ idempotency_key: 'hold-handover-2027-03-02',
780
+ },
781
+ },
782
+ {
783
+ name: 'slotlock_release_slots',
784
+ title: 'Release held slots',
785
+ description: 'Release one of your holds, and the rest of its group, at once instead of letting them expire. `released` is false when it was already gone.',
786
+ input: z.object({ hold_id: HOLD_ID }).strict(),
787
+ output: z.object({ released: z.boolean() }).strict(),
788
+ risk: { readOnly: false, destructive: false, idempotent: true },
789
+ invoke: releaseSlots,
790
+ example: { hold_id: '7d3f8c2a-4b1e-4f6a-9c2d-1e5b7a9f3c4d' },
791
+ },
507
792
  ];
508
- const FORMATS_WITHOUT_PATTERN = new Set(['date-time', 'email']);
793
+ const FORMATS_WITHOUT_PATTERN = new Set(['date-time', 'email', 'uuid']);
509
794
  function toJsonSchema(schema, io) {
510
795
  // Zod's own converter: zod-to-json-schema reads only Zod 3 schemas (for a Zod 4 one it returns {})
511
796
  // and its repository is archived. A type JSON Schema cannot express throws rather than vanishing.
@@ -514,7 +799,7 @@ function toJsonSchema(schema, io) {
514
799
  io,
515
800
  unrepresentable: 'throw',
516
801
  override: ({ jsonSchema }) => {
517
- // Zod 4.6 writes its validation regex beside `format` for dates and emails (288 and 102
802
+ // Zod 4.6 writes its validation regex beside `format` for dates, emails and UUIDs (288 and 102
518
803
  // characters, on 60 fields): the format already says it, and every agent reads the list.
519
804
  if (FORMATS_WITHOUT_PATTERN.has(String(jsonSchema.format)))
520
805
  delete jsonSchema.pattern;
@@ -603,18 +888,32 @@ export function slotlockAgentTools() {
603
888
  idempotentHint: operation.risk.idempotent,
604
889
  openWorldHint: false,
605
890
  },
606
- ...(operation.risk.readOnly
607
- ? {
608
- _meta: {
609
- ui: {
610
- resourceUri: SLOTLOCK_MCP_APP_RESOURCE_URI,
611
- visibility: ['model', 'app'],
612
- },
613
- },
614
- }
615
- : {}),
891
+ // Reads render in the calendar view, which may call them again. A write is the model's to make
892
+ // through the normal path, where a person can be asked to confirm it: never a view's, which
893
+ // MCP Apps would otherwise allow (visibility defaults to model and app).
894
+ // Held slots render there too, to pick from, but only the model holds.
895
+ _meta: {
896
+ ui: operation.risk.readOnly
897
+ ? { resourceUri: SLOTLOCK_MCP_APP_RESOURCE_URI, visibility: ['model', 'app'] }
898
+ : operation.name === 'slotlock_hold_slots'
899
+ ? { resourceUri: SLOTLOCK_MCP_APP_RESOURCE_URI, visibility: ['model'] }
900
+ : { visibility: ['model'] },
901
+ },
616
902
  }));
617
903
  }
904
+ /** The resource and interval a refused write asked for, as an overlap's problem details name them. */
905
+ function refusedInterval(input) {
906
+ const named = {};
907
+ const fields = [
908
+ ['resource_id', input.resource_id],
909
+ ['start', input.starts_at ?? input.start],
910
+ ['end', input.ends_at ?? input.end],
911
+ ];
912
+ for (const [name, value] of fields)
913
+ if (typeof value === 'string')
914
+ named[name] = value;
915
+ return named;
916
+ }
618
917
  function json(status, body, extraHeaders = {}) {
619
918
  return new Response(JSON.stringify(body), {
620
919
  status,
@@ -874,7 +1173,11 @@ export async function invokeSlotlockAgentOperation(args) {
874
1173
  if (halt)
875
1174
  return { ok: false, status: halt.status, code: halt.code };
876
1175
  }
877
- const method = args.options.backend[operation.invoke];
1176
+ const invoke = operation.invoke;
1177
+ const backend = args.options.backend;
1178
+ const method = typeof invoke === 'function'
1179
+ ? (context, input) => invoke(backend, context, input)
1180
+ : backend[invoke];
878
1181
  const context = {
879
1182
  principal,
880
1183
  operation: operation.name,
@@ -882,7 +1185,7 @@ export async function invokeSlotlockAgentOperation(args) {
882
1185
  };
883
1186
  if (args.trace)
884
1187
  context.trace = args.trace;
885
- const data = await method.call(args.options.backend, context, parsed.data);
1188
+ const data = await method.call(backend, context, parsed.data);
886
1189
  if (!operation.output.safeParse(data).success) {
887
1190
  // Neither the result nor the schema's complaint about it: either may hold tenant data.
888
1191
  reportUnexpected(args.options.onError, Object.assign(new Error(`The backend's ${operation.name} result does not match the operation's output schema`), { code: 'invalid_backend_result' }), resolvedOperation);
@@ -892,7 +1195,12 @@ export async function invokeSlotlockAgentOperation(args) {
892
1195
  }
893
1196
  catch (error) {
894
1197
  if (error instanceof SlotlockAgentOperationError) {
895
- return { ok: false, status: error.status, code: error.code };
1198
+ return {
1199
+ ok: false,
1200
+ status: error.status,
1201
+ code: error.code,
1202
+ ...(error.details ? { details: error.details } : {}),
1203
+ };
896
1204
  }
897
1205
  reportUnexpected(args.options.onError, error, resolvedOperation);
898
1206
  return { ok: false, status: 500, code: 'internal_error' };
@@ -1152,12 +1460,14 @@ function validateSubscriptions(value) {
1152
1460
  }
1153
1461
  return setting;
1154
1462
  };
1463
+ const maxTotal = bounded('maxTotal', 1, 10_000, 256);
1155
1464
  return {
1156
1465
  pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, 10_000),
1157
1466
  maxDurationMs: bounded('maxDurationMs', 60_000, 86_400_000, 900_000),
1158
1467
  keepAliveMs: bounded('keepAliveMs', 1_000, 60_000, 15_000),
1159
1468
  maxPerPrincipal: bounded('maxPerPrincipal', 1, 64, 4),
1160
- maxTotal: bounded('maxTotal', 1, 10_000, 256),
1469
+ maxPerTenant: bounded('maxPerTenant', 1, 10_000, maxTotal),
1470
+ maxTotal,
1161
1471
  };
1162
1472
  }
1163
1473
  /**
@@ -1203,13 +1513,15 @@ function unref(timer) {
1203
1513
  /** Natural-language guidance `server/discover` gives the model on how to use these tools well. */
1204
1514
  const SLOTLOCK_MCP_INSTRUCTIONS = [
1205
1515
  'Slotlock is a tenant-isolated resource calendar (vehicles, rooms, equipment).',
1206
- 'Check availability before booking: slotlock_get_free_busy returns busy intervals and slotlock_find_next_available the earliest free slot of a given length; an answer is certain only when coverage.certainty is "certain".',
1516
+ 'Check availability before booking: slotlock_get_free_busy returns busy intervals and slotlock_find_next_available the earliest free slot of a given length, and slotlock_find_available_slots up to 50 of them to offer a person a choice; an answer is certain only when coverage.certainty is "certain".',
1207
1517
  'slotlock_create_event needs an idempotency_key: retry with the same key and arguments, never a new key for the same booking. slotlock_update_event and slotlock_delete_event need the event revision you last read as expected_revision.',
1208
1518
  'Times are RFC 3339 with an offset; intervals are half-open [start, end).',
1209
1519
  'A failed call returns {"error":{"code":"…"}}; read the code before retrying (overlap means the slot is taken).',
1210
1520
  ].join(' ');
1211
1521
  const WRITE_OPERATIONS = new Set([
1212
1522
  'slotlock_create_event',
1523
+ 'slotlock_book_together',
1524
+ 'slotlock_book_pool',
1213
1525
  'slotlock_update_event',
1214
1526
  'slotlock_delete_event',
1215
1527
  ]);
@@ -1242,7 +1554,15 @@ function validateConfirmation(value) {
1242
1554
  if (!Number.isInteger(ttlSeconds) || ttlSeconds < 30 || ttlSeconds > 3_600) {
1243
1555
  throw invalid('ttlSeconds');
1244
1556
  }
1245
- return { operations: new Set(value.operations), keys, ttlMs: ttlSeconds * 1_000 };
1557
+ if (value.consumeAcceptance !== undefined && typeof value.consumeAcceptance !== 'function') {
1558
+ throw invalid('consumeAcceptance');
1559
+ }
1560
+ return {
1561
+ operations: new Set(value.operations),
1562
+ keys,
1563
+ ttlMs: ttlSeconds * 1_000,
1564
+ ...(value.consumeAcceptance ? { consumeAcceptance: value.consumeAcceptance } : {}),
1565
+ };
1246
1566
  }
1247
1567
  /** An empty `elicitation` object declares form mode (2026-07-28 client/elicitation §Capabilities). */
1248
1568
  function clientSupportsFormElicitation(capabilities) {
@@ -1451,10 +1771,14 @@ function promptSeries(series, zone) {
1451
1771
  const booked = expandCalendarEventOccurrences(content, horizon);
1452
1772
  beyond = promptSeriesEnd(series.rule, booked.length, horizon.end);
1453
1773
  const length = end.getTime() - start.getTime();
1774
+ // Moved: by an exception, to another time or length. One the clocks change inside, or that
1775
+ // the store ends where the next starts, is where its rule puts it.
1776
+ const excepted = new Set(exceptions.flatMap((exception) => exception.cancelled !== true && exception.start ? [exception.recurrenceId.getTime()] : []));
1454
1777
  moved = promptMoved(booked.flatMap((occurrence) => {
1455
1778
  const from = new Date(occurrence.recurrenceId);
1456
- const shifted = occurrence.start.getTime() !== from.getTime() ||
1457
- occurrence.end.getTime() - occurrence.start.getTime() !== length;
1779
+ const shifted = excepted.has(from.getTime()) &&
1780
+ (occurrence.start.getTime() !== from.getTime() ||
1781
+ occurrence.end.getTime() - occurrence.start.getTime() !== length);
1458
1782
  return shifted && Number.isFinite(from.getTime())
1459
1783
  ? [{ from, start: occurrence.start, end: occurrence.end }]
1460
1784
  : [];
@@ -1595,6 +1919,23 @@ function confirmationLookup(options, context) {
1595
1919
  * part has its own bound, so nothing is cut from the end.
1596
1920
  */
1597
1921
  async function confirmationMessage(operation, input, lookup) {
1922
+ if (operation === 'slotlock_book_pool') {
1923
+ const zone = promptZone([[input.timezone, false]]);
1924
+ const title = typeof input.title === 'string' && input.title.length > 0 ? `: ${promptQuoted(input.title)}` : '';
1925
+ return `Book any one of pool ${promptText(input.pool, 100)} for ${promptRange(promptInstantOf(input.starts_at), promptInstantOf(input.ends_at), zone)}${title}.`;
1926
+ }
1927
+ if (operation === 'slotlock_book_together') {
1928
+ const ids = Array.isArray(input.resource_ids) ? input.resource_ids : [];
1929
+ const resources = await Promise.all(ids.map((id) => lookup.resource(id)));
1930
+ const zone = promptZone([
1931
+ ...resources.map((resource) => [resource.timezone, true]),
1932
+ [input.timezone, false],
1933
+ ]);
1934
+ const labels = resources.map((resource) => resource.label);
1935
+ const all = labels.length > 1 ? `${labels.slice(0, -1).join(', ')} and ${labels.at(-1)}` : (labels[0] ?? '');
1936
+ const title = typeof input.title === 'string' && input.title.length > 0 ? `: ${promptQuoted(input.title)}` : '';
1937
+ return `Book ${all} together, all or none, for ${promptRange(promptInstantOf(input.starts_at), promptInstantOf(input.ends_at), zone)}${title}.`;
1938
+ }
1598
1939
  if (operation === 'slotlock_create_event') {
1599
1940
  const resource = await lookup.resource(input.resource_id);
1600
1941
  const zone = promptZone([
@@ -1673,8 +2014,18 @@ async function confirmationMessage(operation, input, lookup) {
1673
2014
  changes.push('no longer repeats');
1674
2015
  }
1675
2016
  else if (typeof rule === 'string' && reshaped && seriesStart && seriesEnd) {
1676
- const seriesZone = input.timezone ?? event?.timezone ?? 'UTC';
1677
- changes.push(`so it books ${promptSeries({ start: seriesStart, end: seriesEnd, timezone: seriesZone, rule, exceptions }, zone)}`);
2017
+ const seriesZone = input.timezone ?? event?.timezone;
2018
+ if (seriesZone === undefined) {
2019
+ // An unread event and no new zone: the store expands in the event's own, which the question
2020
+ // cannot know, so it states no dates.
2021
+ changes.push(`repeats ${promptRule(rule)}, on dates that follow the event's existing time zone`);
2022
+ if (input.recurrence_exceptions !== undefined) {
2023
+ changes.push(`exceptions to ${promptRequestedExceptions(input.recurrence_exceptions, zone)}`);
2024
+ }
2025
+ }
2026
+ else {
2027
+ changes.push(`so it books ${promptSeries({ start: seriesStart, end: seriesEnd, timezone: seriesZone, rule, exceptions }, zone)}`);
2028
+ }
1678
2029
  }
1679
2030
  else {
1680
2031
  if (typeof input.recurrence_rule === 'string') {
@@ -1771,6 +2122,22 @@ function a2aError(id, code, message, info) {
1771
2122
  },
1772
2123
  });
1773
2124
  }
2125
+ /**
2126
+ * The request's principal, or the error when `authenticate` throws a 503
2127
+ * `SlotlockAgentOperationError`, as createSlotlockApiKeyAuthenticator does when its lookup queue is
2128
+ * full: no answer about the credential, so the client is told to retry rather than that it is
2129
+ * anonymous.
2130
+ */
2131
+ async function principalOrBusy(authenticate, request) {
2132
+ try {
2133
+ return await authenticate(request);
2134
+ }
2135
+ catch (error) {
2136
+ if (error instanceof SlotlockAgentOperationError && error.status === 503)
2137
+ return error;
2138
+ throw error;
2139
+ }
2140
+ }
1774
2141
  /** Map a request path onto the server's base path; `null` when it lies outside it. */
1775
2142
  function routePath(pathname, basePath) {
1776
2143
  if (basePath === '')
@@ -1978,6 +2345,17 @@ export function createSlotlockAgentServer(options) {
1978
2345
  operation,
1979
2346
  argsDigest: gate.argsDigest,
1980
2347
  });
2348
+ if (gate.decision === 'accepted' && confirmation.consumeAcceptance) {
2349
+ // Once only: the same accepted state sent again is refused, on any server.
2350
+ const state = openRequestState(confirmation.keys, params.requestState)?.payload;
2351
+ const nonce = state?.nonce;
2352
+ if (typeof nonce !== 'string' || typeof state?.exp !== 'number') {
2353
+ gate.decision = 'invalid';
2354
+ }
2355
+ else if (!(await confirmation.consumeAcceptance({ nonce, expiresAt: new Date(state.exp) }))) {
2356
+ gate.decision = 'invalid';
2357
+ }
2358
+ }
1981
2359
  // Before the call it decides, as on every other protocol.
1982
2360
  emit({ type: 'confirmation', operation, outcome: CONFIRMATION_OUTCOME[gate.decision] });
1983
2361
  return gate.decision === 'accepted' ? null : CONFIRMATION_HALT[gate.decision];
@@ -2035,7 +2413,7 @@ export function createSlotlockAgentServer(options) {
2035
2413
  if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
2036
2414
  return modernError(500, id, -32603, 'Internal error');
2037
2415
  }
2038
- return modernResult(id, slotlockMcpToolResult(outcome.ok ? outcome : { ok: false, code: outcome.code }));
2416
+ return modernResult(id, slotlockMcpToolResult(outcome));
2039
2417
  }
2040
2418
  /** A backend failure behind a resources/* or subscriptions/* request, as its JSON-RPC error. */
2041
2419
  const modernFailure = (id, outcome) => {
@@ -2185,15 +2563,48 @@ export function createSlotlockAgentServer(options) {
2185
2563
  return modernError(503, id, -32603, 'Server is shutting down');
2186
2564
  const principalKey = `${principal.tenantRef}\0${principal.subject}`;
2187
2565
  let held = 0;
2188
- for (const open of openSubscriptions)
2566
+ let heldByTenant = 0;
2567
+ for (const open of openSubscriptions) {
2189
2568
  if (open.principalKey === principalKey)
2190
2569
  held += 1;
2570
+ if (open.tenantRef === principal.tenantRef)
2571
+ heldByTenant += 1;
2572
+ }
2573
+ if (openSubscriptions.size >= subscriptionPolicy.maxTotal &&
2574
+ held < subscriptionPolicy.maxPerPrincipal &&
2575
+ heldByTenant < subscriptionPolicy.maxPerTenant) {
2576
+ // A full server is shared out: a tenant holding fewer than the one holding most, less one,
2577
+ // ends that tenant's oldest stream, so many tenants within their share cannot keep another
2578
+ // from holding any. A slot still opening has nothing to end yet.
2579
+ const streamsOf = new Map();
2580
+ for (const open of openSubscriptions) {
2581
+ if (!open.streaming)
2582
+ continue;
2583
+ const streams = streamsOf.get(open.tenantRef);
2584
+ if (streams)
2585
+ streams.push(open);
2586
+ else
2587
+ streamsOf.set(open.tenantRef, [open]);
2588
+ }
2589
+ let most = [];
2590
+ for (const streams of streamsOf.values())
2591
+ if (streams.length > most.length)
2592
+ most = streams;
2593
+ if (most.length > heldByTenant + 1)
2594
+ most[0]?.end('preempted');
2595
+ }
2191
2596
  if (openSubscriptions.size >= subscriptionPolicy.maxTotal ||
2192
- held >= subscriptionPolicy.maxPerPrincipal) {
2597
+ held >= subscriptionPolicy.maxPerPrincipal ||
2598
+ heldByTenant >= subscriptionPolicy.maxPerTenant) {
2193
2599
  return modernError(429, id, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, 'Too many subscriptions');
2194
2600
  }
2195
2601
  // The slot is held while access is checked, so concurrent opens cannot overshoot the caps.
2196
- const slot = { principalKey, end: (_reason) => { } };
2602
+ const slot = {
2603
+ principalKey,
2604
+ tenantRef: principal.tenantRef,
2605
+ streaming: false,
2606
+ end: () => { },
2607
+ };
2197
2608
  openSubscriptions.add(slot);
2198
2609
  let streaming = false;
2199
2610
  try {
@@ -2257,6 +2668,7 @@ export function createSlotlockAgentServer(options) {
2257
2668
  });
2258
2669
  }
2259
2670
  slot.end = end;
2671
+ slot.streaming = true;
2260
2672
  stream.send({
2261
2673
  jsonrpc: '2.0',
2262
2674
  method: 'notifications/subscriptions/acknowledged',
@@ -2432,7 +2844,12 @@ export function createSlotlockAgentServer(options) {
2432
2844
  }
2433
2845
  catch (error) {
2434
2846
  if (error instanceof SlotlockAgentOperationError) {
2435
- return { ok: false, status: error.status, code: error.code };
2847
+ return {
2848
+ ok: false,
2849
+ status: error.status,
2850
+ code: error.code,
2851
+ ...(error.details ? { details: error.details } : {}),
2852
+ };
2436
2853
  }
2437
2854
  reportUnexpected(options.onError, error, definition.name);
2438
2855
  return { ok: false, status: 500, code: 'internal_error' };
@@ -2444,7 +2861,12 @@ export function createSlotlockAgentServer(options) {
2444
2861
  * for, so a guarded write answers 428 `confirmation_required`.
2445
2862
  */
2446
2863
  async function restCall(request, path, url) {
2447
- const refuse = (status, code, headers = {}) => json(status, { error: { code } }, headers);
2864
+ // Every refusal is RFC 9457 problem details, still carrying `error.code`.
2865
+ const refuse = (status, code, headers = {}, extensions = {}) => json(status, slotlockProblem(status, code, extensions), {
2866
+ 'Content-Type': 'application/problem+json',
2867
+ Link: `<${SLOTLOCK_PROBLEM_DOCS}>; rel="describedby"; type="text/html"`,
2868
+ ...headers,
2869
+ });
2448
2870
  if (path === '/openapi.json') {
2449
2871
  return request.method === 'GET'
2450
2872
  ? json(200, openApi)
@@ -2463,7 +2885,10 @@ export function createSlotlockAgentServer(options) {
2463
2885
  return refuse(403, 'origin_not_allowed');
2464
2886
  }
2465
2887
  }
2466
- const principal = await options.authenticate(request);
2888
+ const principal = await principalOrBusy(options.authenticate, request);
2889
+ if (principal instanceof SlotlockAgentOperationError) {
2890
+ return refuse(503, principal.code, { 'Retry-After': '1' });
2891
+ }
2467
2892
  if (!principal ||
2468
2893
  !isCanonicalPrincipalIdentity(principal.subject) ||
2469
2894
  !isCanonicalPrincipalIdentity(principal.tenantRef)) {
@@ -2516,6 +2941,28 @@ export function createSlotlockAgentServer(options) {
2516
2941
  if (Object.keys(params).some((name) => Object.hasOwn(given, name))) {
2517
2942
  return refuseCall(400, 'invalid_arguments');
2518
2943
  }
2944
+ // A write's retry identity may come as the Idempotency-Key header (a Structured Field String,
2945
+ // draft-ietf-httpapi-idempotency-key-header-08), where HTTP clients put it, as well as its
2946
+ // `idempotency_key` argument. On an operation that keeps none it is refused rather than
2947
+ // ignored: a key that was dropped would promise a replay it never gives.
2948
+ const inputSchema = restInputSchemas.get(route.operation);
2949
+ const idempotencyHeader = request.headers.get('idempotency-key');
2950
+ if (idempotencyHeader !== null) {
2951
+ if (!Object.hasOwn(inputSchema?.properties ?? {}, 'idempotency_key')) {
2952
+ return refuseCall(400, 'idempotency_key_not_supported');
2953
+ }
2954
+ const key = parseIdempotencyKeyHeader(idempotencyHeader);
2955
+ if (key === null)
2956
+ return refuseCall(400, 'invalid_idempotency_key');
2957
+ if (Object.hasOwn(given, 'idempotency_key') && given.idempotency_key !== key) {
2958
+ return refuseCall(400, 'invalid_arguments');
2959
+ }
2960
+ given = { ...given, idempotency_key: key };
2961
+ }
2962
+ else if (inputSchema?.required?.includes('idempotency_key') &&
2963
+ !Object.hasOwn(given, 'idempotency_key')) {
2964
+ return refuseCall(400, 'idempotency_key_required');
2965
+ }
2519
2966
  const outcome = isSlotlockApplicationOperation(route.operation)
2520
2967
  ? await invokeApplicationOperation(slotlockApplicationOperation(route.operation), principal, { ...given, ...params }, request, trace)
2521
2968
  : await invokeSlotlockAgentOperation({
@@ -2530,7 +2977,16 @@ export function createSlotlockAgentServer(options) {
2530
2977
  report(status);
2531
2978
  if (outcome.ok)
2532
2979
  return json(200, outcome.data);
2533
- return refuse(status, internal ? 'internal_error' : outcome.code);
2980
+ if (internal)
2981
+ return refuse(status, 'internal_error');
2982
+ // The same key with another request is 422 (the Idempotency-Key draft); on MCP and A2A, where
2983
+ // no status is seen, it is the same code.
2984
+ if (outcome.code === 'idempotency_conflict')
2985
+ return refuse(422, outcome.code);
2986
+ return refuse(status, outcome.code, {}, {
2987
+ ...(outcome.code === 'overlap' ? refusedInterval(given) : {}),
2988
+ ...outcome.details,
2989
+ });
2534
2990
  }
2535
2991
  return {
2536
2992
  manifest,
@@ -2576,6 +3032,9 @@ export function createSlotlockAgentServer(options) {
2576
3032
  return json(health.ready ? 200 : 503, {
2577
3033
  status: health.ready ? 'ready' : 'not_ready',
2578
3034
  version: SLOTLOCK_AGENT_SERVER_VERSION,
3035
+ // The time zone rules this process resolves zones with: when they change, maintenance
3036
+ // finds the bookings whose wall time moved (store.followZoneRules).
3037
+ tzdata: process.versions.tz ?? null,
2579
3038
  checks: [...health.checks]
2580
3039
  .slice(0, 100)
2581
3040
  .map((check) => (/^[a-z][a-z0-9_-]{0,63}$/.test(check) ? check : 'invalid')),
@@ -2585,6 +3044,7 @@ export function createSlotlockAgentServer(options) {
2585
3044
  return json(503, {
2586
3045
  status: 'not_ready',
2587
3046
  version: SLOTLOCK_AGENT_SERVER_VERSION,
3047
+ tzdata: process.versions.tz ?? null,
2588
3048
  checks: [],
2589
3049
  });
2590
3050
  }
@@ -2607,7 +3067,17 @@ export function createSlotlockAgentServer(options) {
2607
3067
  return json(403, rpcError(null, -32600, 'Origin not allowed'));
2608
3068
  }
2609
3069
  }
2610
- const principal = await options.authenticate(request);
3070
+ const principal = await principalOrBusy(options.authenticate, request);
3071
+ if (principal instanceof SlotlockAgentOperationError) {
3072
+ // As `slotlock serve` answers it: A2A names the reason in an ErrorInfo, as for a rate limit.
3073
+ const error = { code: -32603, message: 'Server is busy' };
3074
+ const info = {
3075
+ '@type': 'type.googleapis.com/google.rpc.ErrorInfo',
3076
+ reason: 'SERVER_BUSY',
3077
+ domain: 'slotlock',
3078
+ };
3079
+ return json(503, { jsonrpc: '2.0', id: null, error: path === '/a2a' ? { ...error, data: [info] } : error }, { 'Retry-After': '1' });
3080
+ }
2611
3081
  if (!principal ||
2612
3082
  !isCanonicalPrincipalIdentity(principal.subject) ||
2613
3083
  !isCanonicalPrincipalIdentity(principal.tenantRef)) {
@@ -2752,7 +3222,7 @@ export function createSlotlockAgentServer(options) {
2752
3222
  if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
2753
3223
  return json(500, rpcError(id, -32603, 'Internal error'));
2754
3224
  }
2755
- return json(200, rpcResult(id, slotlockMcpToolResult(outcome.ok ? outcome : { ok: false, code: outcome.code })));
3225
+ return json(200, rpcResult(id, slotlockMcpToolResult(outcome)));
2756
3226
  }
2757
3227
  catch {
2758
3228
  return json(500, rpcError(id, -32603, 'Internal error'));
@@ -2874,7 +3344,9 @@ export function createSlotlockAgentServer(options) {
2874
3344
  role: 'ROLE_AGENT',
2875
3345
  parts: [
2876
3346
  {
2877
- data: outcome.ok ? outcome.data : { error: { code: outcome.code } },
3347
+ data: outcome.ok
3348
+ ? outcome.data
3349
+ : { error: { ...outcome.details, code: outcome.code } },
2878
3350
  mediaType: 'application/json',
2879
3351
  },
2880
3352
  ],