slotlock 0.3.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.
package/agent-server.js CHANGED
@@ -6,7 +6,7 @@ import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlock
6
6
  import { SLOTLOCK_EVENT_OUTPUT as EVENT } from './event-schema.js';
7
7
  import { ignoreRejection } from './hooks.js';
8
8
  import { slotlockPackageVersion } from './package-version.js';
9
- 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';
10
10
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, calendarEventRollingHorizon, } from './store.js';
11
11
  import { canonicalRecurrenceRule, expandCalendarEventOccurrences, isBookableRecurrenceRule, } from './sync.js';
12
12
  import { zonedOffsetMinutes, zonedWallTime } from './timezone.js';
@@ -60,9 +60,163 @@ export const SLOTLOCK_A2A_TASK_STATES = Object.freeze([
60
60
  const DATE_TIME = z.string().datetime({ offset: true });
61
61
  const RESOURCE_ID = z.string().trim().min(1).max(200);
62
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}$/);
63
65
  const IDEMPOTENCY_KEY = z.string().trim().min(1).max(200);
64
66
  const REVISION = z.number().int().positive();
65
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
+ }
66
220
  /** The longest slot `slotlock_find_next_available` can look for: the whole search window. */
67
221
  const MAX_SLOT_MINUTES = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60;
68
222
  const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * 24 * 60 * 60 * 1_000;
@@ -177,6 +331,12 @@ const OPERATION_SCOPES = Object.freeze({
177
331
  slotlock_list_resources: 'read',
178
332
  slotlock_get_free_busy: 'read',
179
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',
180
340
  slotlock_create_event: 'write',
181
341
  slotlock_get_event: 'read',
182
342
  slotlock_list_events: 'read',
@@ -197,11 +357,25 @@ function principalScopesCover(principal, operation) {
197
357
  export class SlotlockAgentOperationError extends Error {
198
358
  code;
199
359
  status;
200
- 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) {
201
367
  super(code);
202
368
  this.name = 'SlotlockAgentOperationError';
203
369
  this.code = /^[a-z][a-z0-9_]{0,63}$/.test(code) ? code : 'operation_failed';
204
- 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));
205
379
  }
206
380
  }
207
381
  const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
@@ -224,6 +398,8 @@ const EVENT_CREATE_INPUT = z
224
398
  reminders: z.array(REMINDER_INPUT).max(20).optional(),
225
399
  idempotency_key: IDEMPOTENCY_KEY,
226
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(),
227
403
  })
228
404
  .strict()
229
405
  .refine((value) => boundedEventSpan(value.starts_at, value.ends_at));
@@ -231,11 +407,12 @@ const OPERATION_DEFINITIONS = [
231
407
  {
232
408
  name: 'slotlock_list_resources',
233
409
  title: 'List calendar resources',
234
- 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.',
235
411
  input: z
236
412
  .object({
237
413
  cursor: z.string().max(500).optional(),
238
414
  limit: z.number().int().min(1).max(100).default(50),
415
+ pool: POOL.optional(),
239
416
  })
240
417
  .strict(),
241
418
  output: z
@@ -246,6 +423,7 @@ const OPERATION_DEFINITIONS = [
246
423
  id: RESOURCE_ID,
247
424
  external_ref: z.string().max(500).nullable(),
248
425
  timezone: z.string().min(1).max(100),
426
+ pool: POOL.optional(),
249
427
  })
250
428
  .strict())
251
429
  .max(100),
@@ -310,6 +488,40 @@ const OPERATION_DEFINITIONS = [
310
488
  invoke: 'findNextAvailable',
311
489
  example: { resource_ids: [EXAMPLE_RESOURCE_ID], ...EXAMPLE_WINDOW, duration_minutes: 120 },
312
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
+ },
313
525
  {
314
526
  name: 'slotlock_create_event',
315
527
  title: 'Create resource event',
@@ -327,6 +539,86 @@ const OPERATION_DEFINITIONS = [
327
539
  idempotency_key: 'create-handover-2027-03-02',
328
540
  },
329
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
+ },
330
622
  {
331
623
  name: 'slotlock_get_event',
332
624
  title: 'Get resource event',
@@ -440,8 +732,65 @@ const OPERATION_DEFINITIONS = [
440
732
  idempotency_key: 'cancel-handover-2027-03-02',
441
733
  },
442
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
+ },
443
792
  ];
444
- const FORMATS_WITHOUT_PATTERN = new Set(['date-time', 'email']);
793
+ const FORMATS_WITHOUT_PATTERN = new Set(['date-time', 'email', 'uuid']);
445
794
  function toJsonSchema(schema, io) {
446
795
  // Zod's own converter: zod-to-json-schema reads only Zod 3 schemas (for a Zod 4 one it returns {})
447
796
  // and its repository is archived. A type JSON Schema cannot express throws rather than vanishing.
@@ -450,7 +799,7 @@ function toJsonSchema(schema, io) {
450
799
  io,
451
800
  unrepresentable: 'throw',
452
801
  override: ({ jsonSchema }) => {
453
- // 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
454
803
  // characters, on 60 fields): the format already says it, and every agent reads the list.
455
804
  if (FORMATS_WITHOUT_PATTERN.has(String(jsonSchema.format)))
456
805
  delete jsonSchema.pattern;
@@ -539,18 +888,32 @@ export function slotlockAgentTools() {
539
888
  idempotentHint: operation.risk.idempotent,
540
889
  openWorldHint: false,
541
890
  },
542
- ...(operation.risk.readOnly
543
- ? {
544
- _meta: {
545
- ui: {
546
- resourceUri: SLOTLOCK_MCP_APP_RESOURCE_URI,
547
- visibility: ['model', 'app'],
548
- },
549
- },
550
- }
551
- : {}),
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
+ },
552
902
  }));
553
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
+ }
554
917
  function json(status, body, extraHeaders = {}) {
555
918
  return new Response(JSON.stringify(body), {
556
919
  status,
@@ -810,7 +1173,11 @@ export async function invokeSlotlockAgentOperation(args) {
810
1173
  if (halt)
811
1174
  return { ok: false, status: halt.status, code: halt.code };
812
1175
  }
813
- 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];
814
1181
  const context = {
815
1182
  principal,
816
1183
  operation: operation.name,
@@ -818,7 +1185,7 @@ export async function invokeSlotlockAgentOperation(args) {
818
1185
  };
819
1186
  if (args.trace)
820
1187
  context.trace = args.trace;
821
- const data = await method.call(args.options.backend, context, parsed.data);
1188
+ const data = await method.call(backend, context, parsed.data);
822
1189
  if (!operation.output.safeParse(data).success) {
823
1190
  // Neither the result nor the schema's complaint about it: either may hold tenant data.
824
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);
@@ -828,7 +1195,12 @@ export async function invokeSlotlockAgentOperation(args) {
828
1195
  }
829
1196
  catch (error) {
830
1197
  if (error instanceof SlotlockAgentOperationError) {
831
- 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
+ };
832
1204
  }
833
1205
  reportUnexpected(args.options.onError, error, resolvedOperation);
834
1206
  return { ok: false, status: 500, code: 'internal_error' };
@@ -1141,13 +1513,15 @@ function unref(timer) {
1141
1513
  /** Natural-language guidance `server/discover` gives the model on how to use these tools well. */
1142
1514
  const SLOTLOCK_MCP_INSTRUCTIONS = [
1143
1515
  'Slotlock is a tenant-isolated resource calendar (vehicles, rooms, equipment).',
1144
- '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".',
1145
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.',
1146
1518
  'Times are RFC 3339 with an offset; intervals are half-open [start, end).',
1147
1519
  'A failed call returns {"error":{"code":"…"}}; read the code before retrying (overlap means the slot is taken).',
1148
1520
  ].join(' ');
1149
1521
  const WRITE_OPERATIONS = new Set([
1150
1522
  'slotlock_create_event',
1523
+ 'slotlock_book_together',
1524
+ 'slotlock_book_pool',
1151
1525
  'slotlock_update_event',
1152
1526
  'slotlock_delete_event',
1153
1527
  ]);
@@ -1180,7 +1554,15 @@ function validateConfirmation(value) {
1180
1554
  if (!Number.isInteger(ttlSeconds) || ttlSeconds < 30 || ttlSeconds > 3_600) {
1181
1555
  throw invalid('ttlSeconds');
1182
1556
  }
1183
- 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
+ };
1184
1566
  }
1185
1567
  /** An empty `elicitation` object declares form mode (2026-07-28 client/elicitation §Capabilities). */
1186
1568
  function clientSupportsFormElicitation(capabilities) {
@@ -1537,6 +1919,23 @@ function confirmationLookup(options, context) {
1537
1919
  * part has its own bound, so nothing is cut from the end.
1538
1920
  */
1539
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
+ }
1540
1939
  if (operation === 'slotlock_create_event') {
1541
1940
  const resource = await lookup.resource(input.resource_id);
1542
1941
  const zone = promptZone([
@@ -1946,6 +2345,17 @@ export function createSlotlockAgentServer(options) {
1946
2345
  operation,
1947
2346
  argsDigest: gate.argsDigest,
1948
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
+ }
1949
2359
  // Before the call it decides, as on every other protocol.
1950
2360
  emit({ type: 'confirmation', operation, outcome: CONFIRMATION_OUTCOME[gate.decision] });
1951
2361
  return gate.decision === 'accepted' ? null : CONFIRMATION_HALT[gate.decision];
@@ -2003,7 +2413,7 @@ export function createSlotlockAgentServer(options) {
2003
2413
  if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
2004
2414
  return modernError(500, id, -32603, 'Internal error');
2005
2415
  }
2006
- return modernResult(id, slotlockMcpToolResult(outcome.ok ? outcome : { ok: false, code: outcome.code }));
2416
+ return modernResult(id, slotlockMcpToolResult(outcome));
2007
2417
  }
2008
2418
  /** A backend failure behind a resources/* or subscriptions/* request, as its JSON-RPC error. */
2009
2419
  const modernFailure = (id, outcome) => {
@@ -2434,7 +2844,12 @@ export function createSlotlockAgentServer(options) {
2434
2844
  }
2435
2845
  catch (error) {
2436
2846
  if (error instanceof SlotlockAgentOperationError) {
2437
- 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
+ };
2438
2853
  }
2439
2854
  reportUnexpected(options.onError, error, definition.name);
2440
2855
  return { ok: false, status: 500, code: 'internal_error' };
@@ -2446,7 +2861,12 @@ export function createSlotlockAgentServer(options) {
2446
2861
  * for, so a guarded write answers 428 `confirmation_required`.
2447
2862
  */
2448
2863
  async function restCall(request, path, url) {
2449
- 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
+ });
2450
2870
  if (path === '/openapi.json') {
2451
2871
  return request.method === 'GET'
2452
2872
  ? json(200, openApi)
@@ -2521,20 +2941,27 @@ export function createSlotlockAgentServer(options) {
2521
2941
  if (Object.keys(params).some((name) => Object.hasOwn(given, name))) {
2522
2942
  return refuseCall(400, 'invalid_arguments');
2523
2943
  }
2524
- // An application write's retry identity may come as the Idempotency-Key header, where HTTP
2525
- // clients put it, as well as its `idempotency_key` argument. On an operation that keeps none it
2526
- // is refused rather than ignored: a key that was dropped would promise a replay it never gives.
2527
- // The agent tools take theirs as an argument alone, as they always have.
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);
2528
2949
  const idempotencyHeader = request.headers.get('idempotency-key');
2529
- if (idempotencyHeader !== null && isSlotlockApplicationOperation(route.operation)) {
2530
- const fields = (restInputSchemas.get(route.operation)?.properties ?? {});
2531
- if (!Object.hasOwn(fields, 'idempotency_key')) {
2950
+ if (idempotencyHeader !== null) {
2951
+ if (!Object.hasOwn(inputSchema?.properties ?? {}, 'idempotency_key')) {
2532
2952
  return refuseCall(400, 'idempotency_key_not_supported');
2533
2953
  }
2534
- if (Object.hasOwn(given, 'idempotency_key') && given.idempotency_key !== idempotencyHeader) {
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) {
2535
2958
  return refuseCall(400, 'invalid_arguments');
2536
2959
  }
2537
- given = { ...given, idempotency_key: idempotencyHeader };
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');
2538
2965
  }
2539
2966
  const outcome = isSlotlockApplicationOperation(route.operation)
2540
2967
  ? await invokeApplicationOperation(slotlockApplicationOperation(route.operation), principal, { ...given, ...params }, request, trace)
@@ -2550,7 +2977,16 @@ export function createSlotlockAgentServer(options) {
2550
2977
  report(status);
2551
2978
  if (outcome.ok)
2552
2979
  return json(200, outcome.data);
2553
- 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
+ });
2554
2990
  }
2555
2991
  return {
2556
2992
  manifest,
@@ -2596,6 +3032,9 @@ export function createSlotlockAgentServer(options) {
2596
3032
  return json(health.ready ? 200 : 503, {
2597
3033
  status: health.ready ? 'ready' : 'not_ready',
2598
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,
2599
3038
  checks: [...health.checks]
2600
3039
  .slice(0, 100)
2601
3040
  .map((check) => (/^[a-z][a-z0-9_-]{0,63}$/.test(check) ? check : 'invalid')),
@@ -2605,6 +3044,7 @@ export function createSlotlockAgentServer(options) {
2605
3044
  return json(503, {
2606
3045
  status: 'not_ready',
2607
3046
  version: SLOTLOCK_AGENT_SERVER_VERSION,
3047
+ tzdata: process.versions.tz ?? null,
2608
3048
  checks: [],
2609
3049
  });
2610
3050
  }
@@ -2782,7 +3222,7 @@ export function createSlotlockAgentServer(options) {
2782
3222
  if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
2783
3223
  return json(500, rpcError(id, -32603, 'Internal error'));
2784
3224
  }
2785
- return json(200, rpcResult(id, slotlockMcpToolResult(outcome.ok ? outcome : { ok: false, code: outcome.code })));
3225
+ return json(200, rpcResult(id, slotlockMcpToolResult(outcome)));
2786
3226
  }
2787
3227
  catch {
2788
3228
  return json(500, rpcError(id, -32603, 'Internal error'));
@@ -2904,7 +3344,9 @@ export function createSlotlockAgentServer(options) {
2904
3344
  role: 'ROLE_AGENT',
2905
3345
  parts: [
2906
3346
  {
2907
- data: outcome.ok ? outcome.data : { error: { code: outcome.code } },
3347
+ data: outcome.ok
3348
+ ? outcome.data
3349
+ : { error: { ...outcome.details, code: outcome.code } },
2908
3350
  mediaType: 'application/json',
2909
3351
  },
2910
3352
  ],