slotlock 0.6.3 → 0.7.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 (70) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +40 -0
  17. package/assistant.js +115 -0
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-icon.d.ts +1 -0
  23. package/dashboard-icon.js +5 -0
  24. package/dashboard.d.ts +203 -7
  25. package/dashboard.js +1545 -64
  26. package/ddl.d.ts +51 -12
  27. package/ddl.js +2566 -54
  28. package/event-feed.d.ts +30 -4
  29. package/event-feed.js +171 -19
  30. package/index.d.ts +15 -4
  31. package/index.js +8 -2
  32. package/maintenance.d.ts +10 -0
  33. package/maintenance.js +16 -2
  34. package/metrics.d.ts +41 -0
  35. package/metrics.js +22 -0
  36. package/openapi.json +14675 -5957
  37. package/otel.d.ts +86 -0
  38. package/otel.js +273 -0
  39. package/overage.d.ts +33 -0
  40. package/overage.js +115 -0
  41. package/package.json +1 -1
  42. package/partner-billing.d.ts +81 -0
  43. package/partner-billing.js +333 -0
  44. package/partner-names.d.ts +8 -0
  45. package/partner-names.js +10 -0
  46. package/partners.d.ts +147 -0
  47. package/partners.js +375 -0
  48. package/plans.d.ts +92 -0
  49. package/plans.js +115 -0
  50. package/quotas.d.ts +108 -0
  51. package/quotas.js +258 -0
  52. package/rate-limit.d.ts +21 -1
  53. package/rate-limit.js +82 -15
  54. package/resource-profile.d.ts +135 -0
  55. package/resource-profile.js +263 -0
  56. package/rest-api.d.ts +2 -0
  57. package/rest-api.js +68 -3
  58. package/sandbox.d.ts +70 -0
  59. package/sandbox.js +295 -0
  60. package/self-host.d.ts +33 -5
  61. package/self-host.js +950 -22
  62. package/slotlock.manifest.json +1 -1
  63. package/store.d.ts +373 -4
  64. package/store.js +1212 -118
  65. package/teams.d.ts +25 -0
  66. package/teams.js +63 -0
  67. package/timezone.js +13 -1
  68. package/types.d.ts +95 -1
  69. package/usage.d.ts +106 -0
  70. package/usage.js +238 -0
@@ -8,13 +8,100 @@
8
8
  //
9
9
  // Contract only (names, schemas, routes); nothing from the agent server at runtime. The
10
10
  // store-backed implementation is createSlotlockStoreApplicationBackend.
11
- import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_TYPES } from './event-feed.js';
11
+ import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_TYPES, SLOTLOCK_WEBHOOK_FILTER_REFS, SLOTLOCK_WEBHOOK_FILTER_RESOURCES, SLOTLOCK_MAX_WEBHOOK_ENDPOINTS, } from './event-feed.js';
12
12
  import { z } from 'zod';
13
+ import { SLOTLOCK_APPROVAL_KINDS, SLOTLOCK_MAX_APPROVAL_MINUTES, SLOTLOCK_MAX_APPROVAL_POLICIES, } from './approvals.js';
14
+ import { SLOTLOCK_PARTNER_ACCOUNT, SLOTLOCK_PARTNER_PLAN } from './partner-names.js';
13
15
  import { SLOTLOCK_EVENT_OUTPUT } from './event-schema.js';
14
16
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
15
- /** The scope an application operation needs, whichever operation it is. */
17
+ import { SLOTLOCK_GROUPING_REF, SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_LABEL, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
18
+ /** The scope an application operation needs, but the partner API's (`slotlockApplicationScope`). */
16
19
  export const SLOTLOCK_APPLICATION_SCOPE = 'admin';
20
+ /** The scope one application operation needs: `partner` for the partner API's, else `admin`. */
21
+ export function slotlockApplicationScope(definition) {
22
+ return definition.scope ?? SLOTLOCK_APPLICATION_SCOPE;
23
+ }
24
+ /**
25
+ * Writes that cannot be a dry run: they do not run in one tenant transaction that can be undone
26
+ * (erasure, maintenance's passes, the partner store's).
27
+ */
28
+ const NOT_DRY_RUNNABLE = new Set([
29
+ 'slotlock_erase_tenant',
30
+ 'slotlock_run_maintenance',
31
+ 'slotlock_sandbox_seed',
32
+ 'slotlock_sandbox_reset',
33
+ 'slotlock_sandbox_fast_forward',
34
+ 'slotlock_sandbox_race',
35
+ 'slotlock_partner_put_account',
36
+ 'slotlock_partner_create_key',
37
+ 'slotlock_partner_rotate_key',
38
+ 'slotlock_partner_revoke_key',
39
+ 'slotlock_partner_suspend_account',
40
+ 'slotlock_partner_unsuspend_account',
41
+ 'slotlock_partner_erase_account',
42
+ 'slotlock_partner_reconcile_usage',
43
+ ]);
44
+ /** Whether an application operation takes `dry_run` (RFC 0004 A3): a write that can be undone. */
45
+ export function slotlockApplicationDryRunnable(definition) {
46
+ return !definition.risk.readOnly && !NOT_DRY_RUNNABLE.has(definition.name);
47
+ }
17
48
  const DATE_TIME = z.string().datetime({ offset: true });
49
+ /** A UTC day, `YYYY-MM-DD`, that is a real date. */
50
+ const USAGE_DAY = z
51
+ .string()
52
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
53
+ .refine((day) => new Date(`${day}T00:00:00Z`).toISOString().startsWith(day), 'not a date');
54
+ const USAGE_COUNT = z.number().int().min(0);
55
+ const USAGE_TOTALS = {
56
+ operations: USAGE_COUNT,
57
+ bookings: USAGE_COUNT,
58
+ holds: USAGE_COUNT,
59
+ webhook_deliveries: USAGE_COUNT,
60
+ assistant_messages: USAGE_COUNT,
61
+ resources: USAGE_COUNT,
62
+ };
63
+ const PARTNER_ACCOUNT = z.string().regex(SLOTLOCK_PARTNER_ACCOUNT);
64
+ const PARTNER_PLAN_NAME = z.string().regex(SLOTLOCK_PARTNER_PLAN);
65
+ const PARTNER_ACCOUNT_OUTPUT = z
66
+ .object({
67
+ account: PARTNER_ACCOUNT,
68
+ plan: PARTNER_PLAN_NAME,
69
+ status: z.enum(['active', 'suspended']),
70
+ created_at: z.string().datetime(),
71
+ updated_at: z.string().datetime(),
72
+ resources: z.number().int().min(0).optional(),
73
+ api_keys: z.number().int().min(0).optional(),
74
+ })
75
+ .strict();
76
+ /**
77
+ * Which of an account's environments a key call acts in (RFC 0003 G9): `live`, the default, or
78
+ * `test`, the account's test environment, whose keys start `slk_test_` and are never billed.
79
+ */
80
+ const PARTNER_ENVIRONMENT = z.enum(['live', 'test']).optional();
81
+ const ACCOUNT_KEY = z.string().regex(/^slk_(test_)?[0-9A-Za-z]{46}$/);
82
+ /** An agent key held below the server's limits (RFC 0004 A5): each field only lowers. */
83
+ const KEY_LIMITS = z
84
+ .object({
85
+ max_holds: z.number().int().min(1).max(10_000).optional(),
86
+ max_held_hours: z.number().int().min(1).max(876_000).optional(),
87
+ requests_per_minute: z.number().int().min(1).max(100_000).optional(),
88
+ requires_approval: z.boolean().optional(),
89
+ })
90
+ .strict();
91
+ const PARTNER_KEY_OUTPUT = z
92
+ .object({
93
+ id: z.string().uuid(),
94
+ name: z.string().min(1).max(100),
95
+ prefix: z.string().min(1).max(20),
96
+ scopes: z.array(z.enum(['read', 'write', 'admin'])),
97
+ created_at: z.string().datetime(),
98
+ expires_at: z.string().datetime().nullable(),
99
+ last_used_at: z.string().datetime().nullable(),
100
+ revoked_at: z.string().datetime().nullable(),
101
+ /** What the key may do below the server's limits (RFC 0004 A5); null for the server's. */
102
+ limits: KEY_LIMITS.nullable(),
103
+ })
104
+ .strict();
18
105
  const RESOURCE_ID = z.string().trim().min(1).max(200);
19
106
  const HOLD_ID = z.string().trim().min(1).max(200);
20
107
  const EVENT_ID = z.string().trim().min(1).max(200);
@@ -92,6 +179,106 @@ const BOOKING_RULES = z
92
179
  /** Default booking rules for the tenant or a pool: a resource's own rules but check-in. */
93
180
  const DEFAULT_BOOKING_RULES = BOOKING_RULES.omit({ check_in_minutes: true }).strict();
94
181
  const POOL = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/);
182
+ /** The order slotlock_book_pool tries a pool's members in (RFC 0003 G7). */
183
+ const POOL_ORDER = z.enum(SLOTLOCK_POOL_ORDERS);
184
+ const PROFILE = SLOTLOCK_RESOURCE_PROFILE_LIMITS;
185
+ /** What a resource is: a hint for filtering and display that changes no booking. */
186
+ const RESOURCE_KIND = z.enum(SLOTLOCK_RESOURCE_KINDS);
187
+ const RESOURCE_LABEL = z.string().regex(SLOTLOCK_RESOURCE_LABEL);
188
+ const RESOURCE_ATTRIBUTES = z.record(z.string().min(1).max(PROFILE.attributeKey), z.union([z.string().max(PROFILE.attributeValue), z.number(), z.boolean()]));
189
+ /**
190
+ * A resource's profile, each field optional (resource-profile.ts): a field left out is kept, `null`
191
+ * clears a name, description or capacity, and `[]` and `{}` clear labels and attributes. The store
192
+ * also refuses attributes over 4 KiB, or with more than 50 keys (400 invalid_resource_profile).
193
+ */
194
+ const PROFILE_INPUT = {
195
+ /** Shown to agents and people instead of the reference. */
196
+ name: z.string().min(1).max(PROFILE.name).nullable().optional(),
197
+ description: z.string().min(1).max(PROFILE.description).nullable().optional(),
198
+ kind: RESOURCE_KIND.optional(),
199
+ /** People or seats it holds: informational, never enforced as bookings at once. */
200
+ capacity: z.number().int().min(1).max(PROFILE.capacity).nullable().optional(),
201
+ /** Replaces its labels; sorted and without repeats when stored. */
202
+ labels: z.array(RESOURCE_LABEL).max(PROFILE.labels).optional(),
203
+ /** Replaces its attributes: text, numbers and true or false. */
204
+ attributes: RESOURCE_ATTRIBUTES.optional(),
205
+ };
206
+ /** A location's or a group's reference: as a pool's. */
207
+ const GROUPING_REF = z.string().regex(SLOTLOCK_GROUPING_REF);
208
+ /** An approval policy's reference (RFC 0004 A4). */
209
+ const APPROVAL_POLICY_REF = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/);
210
+ const APPROVAL_APPLIES_TO = z
211
+ .object({
212
+ resource_ids: z.array(z.string().uuid()).max(50).optional(),
213
+ pools: z.array(GROUPING_REF).max(50).optional(),
214
+ locations: z.array(GROUPING_REF).max(50).optional(),
215
+ groups: z.array(GROUPING_REF).max(50).optional(),
216
+ })
217
+ .strict();
218
+ const APPROVAL_CONDITIONS = z
219
+ .object({
220
+ longer_than_minutes: z.number().int().min(0).max(5_270_400).optional(),
221
+ outside_hours: z.boolean().optional(),
222
+ kinds: z.array(z.enum(SLOTLOCK_APPROVAL_KINDS)).min(1).max(4).optional(),
223
+ })
224
+ .strict();
225
+ const APPROVAL_POLICY = z
226
+ .object({
227
+ policy_ref: APPROVAL_POLICY_REF,
228
+ name: z.string().min(1).max(200),
229
+ applies_to: APPROVAL_APPLIES_TO,
230
+ conditions: APPROVAL_CONDITIONS,
231
+ outcome: z.enum(['require_approval', 'refuse']),
232
+ approval_ttl_minutes: z.number().int().min(1).max(SLOTLOCK_MAX_APPROVAL_MINUTES),
233
+ lead_minutes: z.number().int().min(0).max(SLOTLOCK_MAX_APPROVAL_MINUTES),
234
+ created_at: DATE_TIME,
235
+ updated_at: DATE_TIME,
236
+ })
237
+ .strict();
238
+ const APPROVAL_STATUS = z.enum(['pending', 'granted', 'declined', 'expired']);
239
+ /** A write waiting for a person, or decided. */
240
+ const APPROVAL = z
241
+ .object({
242
+ approval_id: z.string().uuid(),
243
+ policy_ref: APPROVAL_POLICY_REF,
244
+ status: APPROVAL_STATUS,
245
+ /** The agent operation it runs when granted, with its arguments as the agent sent them. */
246
+ operation: z.enum(['slotlock_create_event', 'slotlock_book_together', 'slotlock_update_event']),
247
+ request: z.record(z.string(), z.unknown()),
248
+ requested_by: z.string().max(200).nullable(),
249
+ resource_ids: z.array(z.string().uuid()).min(1).max(20),
250
+ starts_at: DATE_TIME,
251
+ ends_at: DATE_TIME,
252
+ expires_at: DATE_TIME,
253
+ decided_by: z.string().max(500).nullable(),
254
+ decided_at: DATE_TIME.nullable(),
255
+ reason: z.string().max(500).nullable(),
256
+ created_at: DATE_TIME,
257
+ })
258
+ .strict();
259
+ /** A place a tenant's resources are at (RFC 0003 G2). */
260
+ const LOCATION = z
261
+ .object({
262
+ ref: GROUPING_REF,
263
+ name: z.string().max(PROFILE.name),
264
+ /** The location it is in; `null` at the top. */
265
+ parent_ref: GROUPING_REF.nullable(),
266
+ /** What a resource created here without a time zone takes; `null` to take its parent's. */
267
+ timezone: TIMEZONE.nullable(),
268
+ /** Resources at it, not counting those under it. */
269
+ resources: z.number().int().min(0),
270
+ })
271
+ .strict();
272
+ /** A set of a tenant's resources (RFC 0003 G3). */
273
+ const GROUP = z
274
+ .object({
275
+ ref: GROUPING_REF,
276
+ name: z.string().max(PROFILE.name),
277
+ description: z.string().max(PROFILE.description).nullable(),
278
+ /** Resources in it. */
279
+ members: z.number().int().min(0),
280
+ })
281
+ .strict();
95
282
  const RESOURCE = z
96
283
  .object({
97
284
  id: RESOURCE_ID,
@@ -111,6 +298,23 @@ const RESOURCE = z
111
298
  archived_at: DATE_TIME.nullable(),
112
299
  /** The pool it is a member of (a vehicle class, a room type); `null` when none. */
113
300
  pool: POOL.nullable(),
301
+ /** Its name; `null` when it has none, and agents and people see the reference. */
302
+ name: z.string().max(PROFILE.name).nullable(),
303
+ description: z.string().max(PROFILE.description).nullable(),
304
+ /** What it is; `other` when not said. */
305
+ kind: RESOURCE_KIND,
306
+ /** People or seats it holds; `null` when not said. */
307
+ capacity: z.number().int().nullable(),
308
+ labels: z.array(RESOURCE_LABEL).max(PROFILE.labels),
309
+ attributes: RESOURCE_ATTRIBUTES,
310
+ /** The location it is at; `null` when not said. */
311
+ location: GROUPING_REF.nullable(),
312
+ /** The groups it is in, by reference. */
313
+ groups: z.array(GROUPING_REF).max(SLOTLOCK_MAX_GROUPS_PER_RESOURCE),
314
+ /** The composite it is a part of (a room of a hall), by id; `null` when it is no part. */
315
+ parent_id: z.string().uuid().nullable(),
316
+ /** Its place among its parent's parts, from 0; `null` when it is no part. */
317
+ part_index: z.number().int().min(0).nullable(),
114
318
  })
115
319
  .strict();
116
320
  const HISTORY_ENTRY = z
@@ -163,6 +367,16 @@ const WEBHOOK = z
163
367
  last_error: z.string().max(500).nullable(),
164
368
  disabled_at: DATE_TIME.nullable(),
165
369
  disabled_reason: z.enum(['gone', 'failing']).nullable(),
370
+ /** Only changes about these resources; `null` when it has no such filter. */
371
+ filter: z
372
+ .object({
373
+ resource_ids: z.array(z.string().uuid()).max(SLOTLOCK_WEBHOOK_FILTER_RESOURCES).optional(),
374
+ pools: z.array(GROUPING_REF).max(SLOTLOCK_WEBHOOK_FILTER_REFS).optional(),
375
+ locations: z.array(GROUPING_REF).max(SLOTLOCK_WEBHOOK_FILTER_REFS).optional(),
376
+ groups: z.array(GROUPING_REF).max(SLOTLOCK_WEBHOOK_FILTER_REFS).optional(),
377
+ })
378
+ .strict()
379
+ .nullable(),
166
380
  })
167
381
  .strict();
168
382
  const WAITLIST_ENTRY = z
@@ -236,20 +450,175 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
236
450
  {
237
451
  name: 'slotlock_create_resource',
238
452
  title: 'Create or update a resource',
239
- description: 'Create the resource with this external reference, or update its time zone if the tenant already has it. Repeating the call changes nothing more. visibility is shared (the default: agents list and book it) or private (agents never see it), fixed at creation: naming another for an existing resource answers 409 resource_visibility_conflict.',
453
+ description: 'Create the resource with this external reference, or update its time zone, and the profile fields and location given, if the tenant already has it. Without a time zone, a new resource takes the nearest one its location, or a location above it, has, else UTC; a location the tenant does not have answers 404 location_not_found. Repeating the call changes nothing more. visibility is shared (the default: agents list and book it) or private (agents never see it), fixed at creation: naming another for an existing resource answers 409 resource_visibility_conflict. Its profile is optional: a name agents and people see instead of the reference, a description, its kind (person, room, desk, space, vehicle, machine, equipment, service or other: a hint, never a rule), its capacity (informational), up to 20 labels to find it by, and attributes you want back.',
240
454
  method: 'POST',
241
455
  path: '/v1/resources',
242
456
  input: z
243
457
  .object({
244
458
  external_ref: IDENTITY,
245
- timezone: TIMEZONE.default('UTC'),
459
+ /** UTC, or its location's, when not given. */
460
+ timezone: TIMEZONE.optional(),
246
461
  visibility: VISIBILITY.optional(),
462
+ ...PROFILE_INPUT,
463
+ /** The location it is at, by reference. */
464
+ location: GROUPING_REF.optional(),
247
465
  })
248
466
  .strict(),
249
467
  output: z.object({ resource: RESOURCE }).strict(),
250
468
  risk: { readOnly: false, destructive: false, idempotent: true },
251
469
  invoke: 'createResource',
252
- example: { external_ref: 'vehicle-42', timezone: 'Europe/London' },
470
+ example: {
471
+ external_ref: 'room-3',
472
+ timezone: 'Europe/London',
473
+ name: 'Room 3',
474
+ kind: 'room',
475
+ capacity: 10,
476
+ labels: ['video', 'whiteboard'],
477
+ },
478
+ },
479
+ {
480
+ name: 'slotlock_update_resource',
481
+ title: "Change a resource's profile",
482
+ description: "Change a resource's name, description, kind, capacity, labels, attributes or location: the fields given, keeping the rest. null clears a name, description, capacity or location; [] and {} clear labels and attributes. A location the tenant does not have answers 404 location_not_found. The feed's resource.updated names the fields that changed (profile_changed), never their values. 400 invalid_resource_profile outside the bounds, naming the field.",
483
+ method: 'PATCH',
484
+ path: '/v1/resources/{resource_id}',
485
+ input: z
486
+ .object({ resource_id: RESOURCE_ID, ...PROFILE_INPUT, location: GROUPING_REF.nullable().optional() })
487
+ .strict()
488
+ .refine((input) => Object.keys(input).some((key) => key !== 'resource_id'), 'Give at least one profile field to change'),
489
+ output: z.object({ resource: RESOURCE }).strict(),
490
+ risk: { readOnly: false, destructive: false, idempotent: true },
491
+ invoke: 'updateResource',
492
+ example: { resource_id: EXAMPLE_RESOURCE_ID, name: 'Board room', labels: ['video'] },
493
+ },
494
+ {
495
+ name: 'slotlock_list_locations',
496
+ title: 'List locations',
497
+ description: `The tenant's locations, each after the one it is in (depth first), with how many resources are at each. A tree of at most ${SLOTLOCK_MAX_LOCATION_DEPTH} levels and ${SLOTLOCK_MAX_LOCATIONS} locations: a region, a site, a building, a floor, a zone, or whatever yours are.`,
498
+ method: 'GET',
499
+ path: '/v1/locations',
500
+ input: z.object({}).strict(),
501
+ output: z.object({ locations: z.array(LOCATION).max(SLOTLOCK_MAX_LOCATIONS) }).strict(),
502
+ risk: { readOnly: true, destructive: false, idempotent: true },
503
+ invoke: 'listLocations',
504
+ example: {},
505
+ },
506
+ {
507
+ name: 'slotlock_create_location',
508
+ title: 'Create or update a location',
509
+ description: `Create the location with this reference, or change its name, and the parent and time zone given. 404 location_not_found for a parent the tenant does not have; 409 location_too_deep past ${SLOTLOCK_MAX_LOCATION_DEPTH} levels, location_cycle under itself, location_limit_reached past ${SLOTLOCK_MAX_LOCATIONS}.`,
510
+ method: 'POST',
511
+ path: '/v1/locations',
512
+ input: z
513
+ .object({
514
+ ref: GROUPING_REF,
515
+ name: z.string().min(1).max(PROFILE.name),
516
+ parent_ref: GROUPING_REF.nullable().optional(),
517
+ timezone: TIMEZONE.nullable().optional(),
518
+ })
519
+ .strict(),
520
+ output: z.object({ location: LOCATION }).strict(),
521
+ risk: { readOnly: false, destructive: false, idempotent: true },
522
+ invoke: 'createLocation',
523
+ example: { ref: 'leeds', name: 'Leeds clinic', timezone: 'Europe/London' },
524
+ },
525
+ {
526
+ name: 'slotlock_update_location',
527
+ title: 'Change a location',
528
+ description: "Change a location's name, parent or time zone: the fields given. Refused as creating one is.",
529
+ method: 'PATCH',
530
+ path: '/v1/locations/{location_ref}',
531
+ input: z
532
+ .object({
533
+ location_ref: GROUPING_REF,
534
+ name: z.string().min(1).max(PROFILE.name).optional(),
535
+ parent_ref: GROUPING_REF.nullable().optional(),
536
+ timezone: TIMEZONE.nullable().optional(),
537
+ })
538
+ .strict()
539
+ .refine((input) => Object.keys(input).some((key) => key !== 'location_ref'), 'Give at least one field to change'),
540
+ output: z.object({ location: LOCATION }).strict(),
541
+ risk: { readOnly: false, destructive: false, idempotent: true },
542
+ invoke: 'updateLocation',
543
+ example: { location_ref: 'leeds', name: 'Leeds city clinic' },
544
+ },
545
+ {
546
+ name: 'slotlock_delete_location',
547
+ title: 'Delete a location',
548
+ description: 'Delete a location. Refused with 409 location_in_use while a resource is at it or a location is in it: move them first. deleted is false when the tenant has no such location.',
549
+ method: 'DELETE',
550
+ path: '/v1/locations/{location_ref}',
551
+ input: z.object({ location_ref: GROUPING_REF }).strict(),
552
+ output: z.object({ deleted: z.boolean() }).strict(),
553
+ risk: { readOnly: false, destructive: true, idempotent: true },
554
+ invoke: 'deleteLocation',
555
+ example: { location_ref: 'leeds' },
556
+ },
557
+ {
558
+ name: 'slotlock_list_groups',
559
+ title: 'List groups',
560
+ description: `The tenant's groups, by reference, with how many resources are in each: any sets you want (a team, a department, a contract), at most ${SLOTLOCK_MAX_RESOURCE_GROUPS}. A group changes nothing about how its resources are booked; a pool does that.`,
561
+ method: 'GET',
562
+ path: '/v1/groups',
563
+ input: z.object({}).strict(),
564
+ output: z.object({ groups: z.array(GROUP).max(SLOTLOCK_MAX_RESOURCE_GROUPS) }).strict(),
565
+ risk: { readOnly: true, destructive: false, idempotent: true },
566
+ invoke: 'listGroups',
567
+ example: {},
568
+ },
569
+ {
570
+ name: 'slotlock_create_group',
571
+ title: 'Create or update a group',
572
+ description: `Create the group with this reference, or change its name, and its description when given. 409 group_limit_reached past ${SLOTLOCK_MAX_RESOURCE_GROUPS}.`,
573
+ method: 'POST',
574
+ path: '/v1/groups',
575
+ input: z
576
+ .object({
577
+ ref: GROUPING_REF,
578
+ name: z.string().min(1).max(PROFILE.name),
579
+ description: z.string().min(1).max(PROFILE.description).nullable().optional(),
580
+ })
581
+ .strict(),
582
+ output: z.object({ group: GROUP }).strict(),
583
+ risk: { readOnly: false, destructive: false, idempotent: true },
584
+ invoke: 'createGroup',
585
+ example: { ref: 'physio', name: 'Physiotherapy team' },
586
+ },
587
+ {
588
+ name: 'slotlock_delete_group',
589
+ title: 'Delete a group',
590
+ description: 'Delete a group; its resources stay as they are. deleted is false when the tenant has no such group.',
591
+ method: 'DELETE',
592
+ path: '/v1/groups/{group_ref}',
593
+ input: z.object({ group_ref: GROUPING_REF }).strict(),
594
+ output: z.object({ deleted: z.boolean() }).strict(),
595
+ risk: { readOnly: false, destructive: true, idempotent: true },
596
+ invoke: 'deleteGroup',
597
+ example: { group_ref: 'physio' },
598
+ },
599
+ {
600
+ name: 'slotlock_add_group_member',
601
+ title: 'Put a resource in a group',
602
+ description: `Put one of the tenant's resources in a group. added is false when it was in it already. 404 group_not_found or resource_not_found; 409 resource_group_limit_reached past ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE} groups a resource.`,
603
+ method: 'PUT',
604
+ path: '/v1/groups/{group_ref}/members/{resource_id}',
605
+ input: z.object({ group_ref: GROUPING_REF, resource_id: RESOURCE_ID }).strict(),
606
+ output: z.object({ added: z.boolean() }).strict(),
607
+ risk: { readOnly: false, destructive: false, idempotent: true },
608
+ invoke: 'addGroupMember',
609
+ example: { group_ref: 'physio', resource_id: EXAMPLE_RESOURCE_ID },
610
+ },
611
+ {
612
+ name: 'slotlock_remove_group_member',
613
+ title: 'Take a resource out of a group',
614
+ description: 'Take a resource out of a group. removed is false when it was not in it.',
615
+ method: 'DELETE',
616
+ path: '/v1/groups/{group_ref}/members/{resource_id}',
617
+ input: z.object({ group_ref: GROUPING_REF, resource_id: RESOURCE_ID }).strict(),
618
+ output: z.object({ removed: z.boolean() }).strict(),
619
+ risk: { readOnly: false, destructive: true, idempotent: true },
620
+ invoke: 'removeGroupMember',
621
+ example: { group_ref: 'physio', resource_id: EXAMPLE_RESOURCE_ID },
253
622
  },
254
623
  {
255
624
  name: 'slotlock_get_resource',
@@ -382,20 +751,48 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
382
751
  invoke: 'setResourcePool',
383
752
  example: { resource_id: EXAMPLE_RESOURCE_ID, pool: 'van' },
384
753
  },
754
+ {
755
+ name: 'slotlock_set_resource_parent',
756
+ title: "Make a resource part of another",
757
+ description: "Make a resource a part of a composite (a room of a hall, a half of a court), or no part (`null`). A booking of the composite as a whole then conflicts with a booking of any of its parts, and two parts never conflict with each other; each part's free/busy shows the whole's bookings, and the whole's shows its parts'. One level deep, at most 50 parts; refused while either side has a booking or live hold still to come (`composite_busy`).",
758
+ method: 'PUT',
759
+ path: '/v1/resources/{resource_id}/parent',
760
+ input: z.object({ resource_id: RESOURCE_ID, parent_id: RESOURCE_ID.nullable() }).strict(),
761
+ output: z.object({ resource: RESOURCE }).strict(),
762
+ risk: { readOnly: false, destructive: false, idempotent: true },
763
+ invoke: 'setResourceParent',
764
+ example: { resource_id: EXAMPLE_RESOURCE_ID, parent_id: '0e7c1f9a-3b5d-4c2e-9f81-6a4b2d8c7e10' },
765
+ },
385
766
  {
386
767
  name: 'slotlock_list_pools',
387
768
  title: 'List pools',
388
- description: "The tenant's pools by name, each with its capacity: how many members it has that are not archived.",
769
+ description: "The tenant's pools by name, each with its capacity (how many members it has that are not archived) and the order slotlock_book_pool tries them in.",
389
770
  method: 'GET',
390
771
  path: '/v1/pools',
391
772
  input: z.object({}).strict(),
392
773
  output: z
393
- .object({ pools: z.array(z.object({ pool: POOL, capacity: z.number().int().min(1) }).strict()).max(1000) })
774
+ .object({
775
+ pools: z
776
+ .array(z.object({ pool: POOL, capacity: z.number().int().min(1), order: POOL_ORDER }).strict())
777
+ .max(1000),
778
+ })
394
779
  .strict(),
395
780
  risk: { readOnly: true, destructive: false, idempotent: true },
396
781
  invoke: 'listPools',
397
782
  example: {},
398
783
  },
784
+ {
785
+ name: 'slotlock_set_pool_order',
786
+ title: "Set a pool's order",
787
+ description: "The order slotlock_book_pool tries a pool's members in: stable (by id, the default), least_booked (fewest bookings made on each in the last 30 days first, to share them out) or round_robin (from the member after the one booked last). At most 1,000 pools have one: one more is 409 pool_order_limit_reached.",
788
+ method: 'PUT',
789
+ path: '/v1/pools/{pool}/order',
790
+ input: z.object({ pool: POOL, order: POOL_ORDER }).strict(),
791
+ output: z.object({ pool: POOL, order: POOL_ORDER }).strict(),
792
+ risk: { readOnly: false, destructive: false, idempotent: true },
793
+ invoke: 'setPoolOrder',
794
+ example: { pool: 'van', order: 'least_booked' },
795
+ },
399
796
  {
400
797
  name: 'slotlock_get_booking_rule_defaults',
401
798
  title: 'Get default booking rules',
@@ -662,7 +1059,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
662
1059
  {
663
1060
  name: 'slotlock_join_waitlist',
664
1061
  title: 'Join the waitlist',
665
- description: "Wait for a window on one resource (resource_id) or on any member of a pool (pool) that is taken now. When it comes free (a booking cancelled, a hold released or expired, a no-show), the entries waiting for it are offered in the order they joined: the first gets a hold of it for holder_ref, for offer_ttl_seconds (default 900), and the feed says so (waitlist.offered, with hold_id): confirm the hold to book it. An entry whose window starts first expires (waitlist.expired). A holder may wait for 20 windows at once (409 waitlist_limit_reached); a window is at most 31 days.",
1062
+ description: "Wait for a window on one resource (resource_id) or on any member of a pool (pool) that is taken now. When it comes free (a booking cancelled, a hold released or expired, a no-show), the entries waiting for it are offered in the order they joined: the first gets a hold of it for holder_ref, for offer_ttl_seconds (default 900), and the feed says so (waitlist.offered, with hold_id): confirm the hold to book it. An entry whose window starts first expires (waitlist.expired). A holder may wait for 20 windows at once (409 waitlist_limit_reached); a window is at most 31 days. With an idempotency key (the Idempotency-Key header or idempotency_key), a retry answers the entry the first request made (replayed: true) for as long as it is kept; the same key with another request is 422 idempotency_conflict.",
666
1063
  method: 'POST',
667
1064
  path: '/v1/waitlist',
668
1065
  input: z
@@ -673,13 +1070,14 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
673
1070
  end: DATE_TIME,
674
1071
  holder_ref: z.string().trim().min(1).max(500),
675
1072
  offer_ttl_seconds: z.number().int().min(60).max(86_400).optional(),
1073
+ idempotency_key: IDEMPOTENCY_KEY.optional(),
676
1074
  })
677
1075
  .strict()
678
1076
  .refine((input) => (input.resource_id === undefined) !== (input.pool === undefined), {
679
1077
  message: 'Name resource_id or pool, not both',
680
1078
  }),
681
- output: z.object({ entry: WAITLIST_ENTRY }).strict(),
682
- risk: { readOnly: false, destructive: false, idempotent: false },
1079
+ output: z.object({ entry: WAITLIST_ENTRY, replayed: z.boolean() }).strict(),
1080
+ risk: { readOnly: false, destructive: false, idempotent: true },
683
1081
  invoke: 'joinWaitlist',
684
1082
  example: {
685
1083
  pool: 'van',
@@ -900,7 +1298,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
900
1298
  {
901
1299
  name: 'slotlock_erase_tenant',
902
1300
  title: 'Erase the tenant',
903
- description: "Delete everything the key's tenant has, at once and for good: resources, holds, bookings, events, coverage and every API key, this one included. confirm_tenant_ref must be the tenant's reference (slotlock_get_tenant), or it answers 400 invalid_confirmation. Off unless the server allows it (403 tenant_erasure_disabled; slotlock serve does not: its operator runs slotlock tenant erase). A suspended tenant cannot erase itself (403 tenant_suspended). Erasing again deletes nothing.",
1301
+ description: "Delete everything the key's tenant has, at once and for good: resources, holds, bookings, events, coverage and every API key, this one included. confirm_tenant_ref must be the tenant's reference (slotlock_get_tenant), or it answers 400 invalid_confirmation. Off unless the server allows it (403 tenant_erasure_disabled; slotlock serve does not: its operator runs slotlock tenant erase). A suspended tenant cannot erase itself (403 tenant_suspended), nor can one whose hosted plan is still paid for until it is cancelled (409 billing_active). Erasing again deletes nothing.",
904
1302
  method: 'POST',
905
1303
  path: '/v1/tenant/erase',
906
1304
  input: z.object({ confirm_tenant_ref: IDENTITY }).strict(),
@@ -932,6 +1330,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
932
1330
  stale_sources: z.number().int().min(0),
933
1331
  no_shows: z.number().int().min(0),
934
1332
  waitlist_offers: z.number().int().min(0),
1333
+ approvals_expired: z.number().int().min(0),
935
1334
  capped: z.boolean(),
936
1335
  })
937
1336
  .strict(),
@@ -956,17 +1355,22 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
956
1355
  {
957
1356
  name: 'slotlock_create_webhook',
958
1357
  title: 'Add a webhook',
959
- description: 'Send the changes slotlock_list_changes lists from now on to an https URL, each as a CloudEvent signed as Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature), in order, until a 2xx. A failure is retried with backoff; after 24 hours of failures, or a 410, the webhook is disabled and the feed records webhook.disabled. The secret is returned here only. At most 10 per tenant.',
1358
+ description: `Send the changes slotlock_list_changes lists from now on to an https URL, each as a CloudEvent signed as Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature), in order, until a 2xx. A failure is retried with backoff; after 24 hours of failures, or a 410, the webhook is disabled and the feed records webhook.disabled. The secret is returned here only. At most ${SLOTLOCK_MAX_WEBHOOK_ENDPOINTS} per tenant, or fewer on a plan (409 webhook_limit_reached). With resource_ids, pools, locations (each with every location in it) or groups, only changes about a resource that matches one of them, as it is when the change is delivered: a change about no resource (a key, a webhook, a usage notice) is not sent. At most ${SLOTLOCK_WEBHOOK_FILTER_RESOURCES} resource ids and ${SLOTLOCK_WEBHOOK_FILTER_REFS} of each other kind (400 invalid_webhook_filter). With an idempotency key (the Idempotency-Key header or idempotency_key), a retry answers the webhook and secret the first request made (replayed: true) for as long as the webhook is kept; the same key with another request is 422 idempotency_conflict.`,
960
1359
  method: 'POST',
961
1360
  path: '/v1/webhooks',
962
1361
  input: z
963
1362
  .object({
964
1363
  url: z.string().url().max(2048),
965
1364
  event_types: z.array(z.enum(SLOTLOCK_CHANGE_TYPES)).min(1).max(SLOTLOCK_CHANGE_TYPES.length).optional(),
1365
+ resource_ids: z.array(z.string().uuid()).min(1).max(SLOTLOCK_WEBHOOK_FILTER_RESOURCES).optional(),
1366
+ pools: z.array(GROUPING_REF).min(1).max(SLOTLOCK_WEBHOOK_FILTER_REFS).optional(),
1367
+ locations: z.array(GROUPING_REF).min(1).max(SLOTLOCK_WEBHOOK_FILTER_REFS).optional(),
1368
+ groups: z.array(GROUPING_REF).min(1).max(SLOTLOCK_WEBHOOK_FILTER_REFS).optional(),
1369
+ idempotency_key: IDEMPOTENCY_KEY.optional(),
966
1370
  })
967
1371
  .strict(),
968
- output: z.object({ webhook: WEBHOOK, secret: z.string().regex(/^whsec_/) }).strict(),
969
- risk: { readOnly: false, destructive: false, idempotent: false },
1372
+ output: z.object({ webhook: WEBHOOK, secret: z.string().regex(/^whsec_/), replayed: z.boolean() }).strict(),
1373
+ risk: { readOnly: false, destructive: false, idempotent: true },
970
1374
  invoke: 'createWebhook',
971
1375
  example: { url: 'https://example.com/slotlock-webhook', event_types: ['reservation.created'] },
972
1376
  },
@@ -977,7 +1381,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
977
1381
  method: 'GET',
978
1382
  path: '/v1/webhooks',
979
1383
  input: z.object({}).strict(),
980
- output: z.object({ webhooks: z.array(WEBHOOK).max(10) }).strict(),
1384
+ output: z.object({ webhooks: z.array(WEBHOOK).max(SLOTLOCK_MAX_WEBHOOK_ENDPOINTS) }).strict(),
981
1385
  risk: { readOnly: true, destructive: false, idempotent: true },
982
1386
  invoke: 'listWebhooks',
983
1387
  example: {},
@@ -1006,6 +1410,413 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
1006
1410
  invoke: 'enableWebhook',
1007
1411
  example: { webhook_id: '9b2f4c1e-6d3a-4e8b-a1c7-5f0e2d4b6a8c' },
1008
1412
  },
1413
+ {
1414
+ name: 'slotlock_get_usage',
1415
+ title: 'Read usage',
1416
+ description: "How much the tenant used the service from one UTC day to another (inclusive, at most 400 days apart; by default the first of this month to today): operations its keys and the dashboard assistant ran (each call that reached its operation, whatever it returned), bookings (bookings, events and confirmed holds) and holds it made, webhook deliveries its endpoints accepted, messages to the dashboard assistant, and the resources it held (each day's count, archived ones included; in totals, the last day's, not a sum). Totals, then by day, by API key (key_id null for none) and by surface (mcp, a2a, rest, application, dashboard-assistant, webhooks); resources are by day only. Counts only, never what was asked. Empty unless the server meters usage (SLOTLOCK_USAGE=on); operations, deliveries and messages appear within 15 seconds; bookings, holds and resources within the hour. 400 invalid_usage_range when to is before from, the range is longer than 400 days, or to is after today.",
1417
+ method: 'GET',
1418
+ path: '/v1/usage',
1419
+ input: z.object({ from: USAGE_DAY.optional(), to: USAGE_DAY.optional() }).strict(),
1420
+ output: z
1421
+ .object({
1422
+ from: USAGE_DAY,
1423
+ to: USAGE_DAY,
1424
+ totals: z.object(USAGE_TOTALS).strict(),
1425
+ days: z.array(z.object({ day: USAGE_DAY, ...USAGE_TOTALS }).strict()).max(401),
1426
+ keys: z
1427
+ .array(z.object({ key_id: z.string().uuid().nullable(), ...USAGE_TOTALS }).strict())
1428
+ .max(1001),
1429
+ surfaces: z.array(z.object({ origin: z.string().min(1).max(40), ...USAGE_TOTALS }).strict()).max(20),
1430
+ })
1431
+ .strict(),
1432
+ risk: { readOnly: true, destructive: false, idempotent: true },
1433
+ invoke: 'getUsage',
1434
+ example: { from: '2026-11-01', to: '2026-11-30' },
1435
+ },
1436
+ // Approval policies (RFC 0004 A4): writes a person approves before they are made.
1437
+ {
1438
+ name: 'slotlock_list_approval_policies',
1439
+ title: 'List approval policies',
1440
+ description: "The tenant's approval policies, by reference: which agent bookings wait for a person (require_approval) or are refused (refuse).",
1441
+ method: 'GET',
1442
+ path: '/v1/approval-policies',
1443
+ input: z.object({}).strict(),
1444
+ output: z.object({ policies: z.array(APPROVAL_POLICY).max(SLOTLOCK_MAX_APPROVAL_POLICIES) }).strict(),
1445
+ risk: { readOnly: true, destructive: false, idempotent: true },
1446
+ invoke: 'listApprovalPolicies',
1447
+ example: {},
1448
+ },
1449
+ {
1450
+ name: 'slotlock_put_approval_policy',
1451
+ title: 'Create or replace an approval policy',
1452
+ description: "Which agent bookings (slotlock_create_event, slotlock_confirm_slot, slotlock_book_pool, slotlock_book_together, and moves by slotlock_update_event or slotlock_reschedule) a person approves first, or are refused. applies_to names resources, pools, locations (with the locations in them) and groups; empty, every resource. conditions narrow it: longer_than_minutes, outside_hours (outside the resource's bookable hours), kinds (single, pool, bundle, move); a booking meets the policy when it meets every condition given. outcome require_approval holds the exact time the booking would take, under the approval, and answers the agent 409 approval_pending; refuse answers 403 approval_policy_refused. A person has approval_ttl_minutes to decide (default 1,440; at most 43,200, and the server's ceiling), and must decide lead_minutes before the booking starts: the wait is the earlier of the two. Where several apply, refuse wins, then the one that must be decided soonest. At most 50 policies: one more is 409 approval_policy_limit_reached.",
1453
+ method: 'PUT',
1454
+ path: '/v1/approval-policies/{policy_ref}',
1455
+ input: z
1456
+ .object({
1457
+ policy_ref: APPROVAL_POLICY_REF,
1458
+ name: z.string().trim().min(1).max(200),
1459
+ applies_to: APPROVAL_APPLIES_TO.optional(),
1460
+ conditions: APPROVAL_CONDITIONS.optional(),
1461
+ outcome: z.enum(['require_approval', 'refuse']),
1462
+ approval_ttl_minutes: z.number().int().min(1).max(SLOTLOCK_MAX_APPROVAL_MINUTES).optional(),
1463
+ lead_minutes: z.number().int().min(0).max(SLOTLOCK_MAX_APPROVAL_MINUTES).optional(),
1464
+ })
1465
+ .strict(),
1466
+ output: z.object({ policy: APPROVAL_POLICY }).strict(),
1467
+ risk: { readOnly: false, destructive: false, idempotent: true },
1468
+ invoke: 'putApprovalPolicy',
1469
+ example: {
1470
+ policy_ref: 'long-van-hires',
1471
+ name: 'Van hires over a day',
1472
+ applies_to: { pools: ['van'] },
1473
+ conditions: { longer_than_minutes: 1440 },
1474
+ outcome: 'require_approval',
1475
+ approval_ttl_minutes: 240,
1476
+ lead_minutes: 60,
1477
+ },
1478
+ },
1479
+ {
1480
+ name: 'slotlock_delete_approval_policy',
1481
+ title: 'Delete an approval policy',
1482
+ description: 'Stop applying a policy to new bookings. Approvals it already asked for stay as they are, to be decided.',
1483
+ method: 'DELETE',
1484
+ path: '/v1/approval-policies/{policy_ref}',
1485
+ input: z.object({ policy_ref: APPROVAL_POLICY_REF }).strict(),
1486
+ output: z.object({ deleted: z.boolean() }).strict(),
1487
+ risk: { readOnly: false, destructive: true, idempotent: true },
1488
+ invoke: 'deleteApprovalPolicy',
1489
+ example: { policy_ref: 'long-van-hires' },
1490
+ },
1491
+ {
1492
+ name: 'slotlock_list_approvals',
1493
+ title: 'List approvals',
1494
+ description: "The tenant's approvals, newest first: bookings an agent asked for that a policy holds for a person (pending, holding their time until expires_at), and those decided (granted, declined, expired). Filter by status.",
1495
+ method: 'GET',
1496
+ path: '/v1/approvals',
1497
+ input: z
1498
+ .object({
1499
+ status: APPROVAL_STATUS.optional(),
1500
+ limit: z.coerce.number().int().min(1).max(200).default(50),
1501
+ })
1502
+ .strict(),
1503
+ output: z.object({ approvals: z.array(APPROVAL).max(200) }).strict(),
1504
+ risk: { readOnly: true, destructive: false, idempotent: true },
1505
+ invoke: 'listApprovals',
1506
+ example: { status: 'pending', limit: 50 },
1507
+ },
1508
+ {
1509
+ name: 'slotlock_get_approval',
1510
+ title: 'Get an approval',
1511
+ description: 'One approval by id, with the booking it would make. 404 approval_not_found.',
1512
+ method: 'GET',
1513
+ path: '/v1/approvals/{approval_id}',
1514
+ input: z.object({ approval_id: z.string().uuid() }).strict(),
1515
+ output: z.object({ approval: APPROVAL }).strict(),
1516
+ risk: { readOnly: true, destructive: false, idempotent: true },
1517
+ invoke: 'getApproval',
1518
+ example: { approval_id: '3f6b2a1c-8d4e-4b7a-9c2f-1e5d7a9b3c4d' },
1519
+ },
1520
+ {
1521
+ name: 'slotlock_decide_approval',
1522
+ title: 'Grant or decline an approval',
1523
+ description: "Decide a pending approval. grant makes the booking as the agent asked for it, as that agent, in one transaction with letting the approval's hold go, and answers it under booking; the agent's retry with its idempotency key then answers the same booking. A write refused now (a booking rule changed, say) is refused with its own code and the approval stays pending. decline frees the time; the agent's retry answers 403 approval_declined with the reason. 404 approval_not_found; 409 approval_not_pending when already decided; 409 approval_expired after its wait.",
1524
+ method: 'POST',
1525
+ path: '/v1/approvals/{approval_id}/decision',
1526
+ input: z
1527
+ .object({
1528
+ approval_id: z.string().uuid(),
1529
+ decision: z.enum(['grant', 'decline']),
1530
+ reason: z.string().trim().min(1).max(500).optional(),
1531
+ })
1532
+ .strict(),
1533
+ output: z.object({ approval: APPROVAL, booking: z.record(z.string(), z.unknown()).nullable() }).strict(),
1534
+ risk: { readOnly: false, destructive: false, idempotent: false },
1535
+ invoke: 'decideApproval',
1536
+ example: { approval_id: '3f6b2a1c-8d4e-4b7a-9c2f-1e5d7a9b3c4d', decision: 'grant' },
1537
+ },
1538
+ // The sandbox (RFC 0004 A6): a test environment's only (`sandbox_test_only` elsewhere).
1539
+ {
1540
+ name: 'slotlock_sandbox_seed',
1541
+ title: 'Seed the test environment',
1542
+ description: "Fill an empty test environment (a key starting slk_test_) with a scenario to try an agent against: car-rental (cars and vans in pools at two branches, 30-minute turnaround), clinic (practitioners and treatment rooms, half-hour slots on weekdays) or desks (desks on two floors, and a hall that splits in two). Locations, groups, resources with profiles, hours and booking rules, and three bookings tomorrow. 409 sandbox_not_empty when it has resources: reset it first. 403 sandbox_test_only for a live key.",
1543
+ method: 'POST',
1544
+ path: '/v1/sandbox/seed',
1545
+ input: z.object({ scenario: z.enum(['car-rental', 'clinic', 'desks']) }).strict(),
1546
+ output: z
1547
+ .object({
1548
+ scenario: z.enum(['car-rental', 'clinic', 'desks']),
1549
+ resources: z.number().int().min(0),
1550
+ locations: z.number().int().min(0),
1551
+ groups: z.number().int().min(0),
1552
+ pools: z.number().int().min(0),
1553
+ bookings: z.number().int().min(0),
1554
+ })
1555
+ .strict(),
1556
+ risk: { readOnly: false, destructive: false, idempotent: false },
1557
+ invoke: 'sandboxSeed',
1558
+ example: { scenario: 'car-rental' },
1559
+ },
1560
+ {
1561
+ name: 'slotlock_sandbox_reset',
1562
+ title: 'Empty the test environment',
1563
+ description: "Empty the test environment as if new: its resources, locations, groups, bookings, events, holds, waitlist, approval policies and feed. Its API keys, webhooks, plan and usage stay. Rows removed, by table. 403 sandbox_test_only for a live key.",
1564
+ method: 'POST',
1565
+ path: '/v1/sandbox/reset',
1566
+ input: z.object({}).strict(),
1567
+ output: z.object({ removed: z.record(z.string(), z.number().int().min(0)) }).strict(),
1568
+ risk: { readOnly: false, destructive: true, idempotent: true },
1569
+ invoke: 'sandboxReset',
1570
+ example: {},
1571
+ },
1572
+ {
1573
+ name: 'slotlock_sandbox_fast_forward',
1574
+ title: "Move the test environment's waiting forward",
1575
+ description: "Make what would lapse in the test environment within minutes (1-525,600) lapse now: holds (a waitlist offer's and an approval's among them) and pending approvals; then a maintenance pass reaps them as it would have, releasing holds, passing offers on and expiring approvals, on the feed as usual. Bookings and events keep their times; no-shows and series follow the real clock. 403 sandbox_test_only for a live key.",
1576
+ method: 'POST',
1577
+ path: '/v1/sandbox/fast-forward',
1578
+ input: z.object({ minutes: z.number().int().min(1).max(525_600) }).strict(),
1579
+ output: z
1580
+ .object({
1581
+ minutes: z.number().int(),
1582
+ holds: z.number().int().min(0),
1583
+ approvals: z.number().int().min(0),
1584
+ maintenance: z.record(z.string(), z.unknown()),
1585
+ })
1586
+ .strict(),
1587
+ risk: { readOnly: false, destructive: false, idempotent: false },
1588
+ invoke: 'sandboxFastForward',
1589
+ example: { minutes: 60 },
1590
+ },
1591
+ {
1592
+ name: 'slotlock_sandbox_race',
1593
+ title: 'Race agents for one slot in the test environment',
1594
+ description: "Race agents (2-200, default 100), each an agent of its own, for one time on one resource of the test environment, through the same path MCP, A2A and REST use: how many booked (one), how many were refused and why (overlap, with alternatives), any server errors, and how long the answers took. 403 sandbox_test_only for a live key.",
1595
+ method: 'POST',
1596
+ path: '/v1/sandbox/race',
1597
+ input: z
1598
+ .object({
1599
+ resource_id: RESOURCE_ID,
1600
+ starts_at: DATE_TIME,
1601
+ ends_at: DATE_TIME,
1602
+ agents: z.number().int().min(2).max(200).optional(),
1603
+ })
1604
+ .strict()
1605
+ .refine((value) => Date.parse(value.ends_at) > Date.parse(value.starts_at)),
1606
+ output: z
1607
+ .object({
1608
+ agents: z.number().int(),
1609
+ booked: z.number().int().min(0),
1610
+ refused: z.record(z.string(), z.number().int().min(0)),
1611
+ with_alternatives: z.number().int().min(0),
1612
+ server_errors: z.number().int().min(0),
1613
+ p50_ms: z.number().min(0),
1614
+ p99_ms: z.number().min(0),
1615
+ max_ms: z.number().min(0),
1616
+ })
1617
+ .strict(),
1618
+ risk: { readOnly: false, destructive: false, idempotent: false },
1619
+ invoke: 'sandboxRace',
1620
+ example: { resource_id: '00000000-0000-4000-8000-000000000001', starts_at: '2027-03-02T09:00:00Z', ends_at: '2027-03-02T10:00:00Z', agents: 100 },
1621
+ },
1622
+ // The partner API (RFC 0003 P3): a partner key's, for that partner's own accounts only.
1623
+ {
1624
+ name: 'slotlock_partner_put_account',
1625
+ title: 'Create or update an account',
1626
+ description: "Create the partner's account with this id (your own id for your user, 1-128 letters, digits, dots, dashes or underscores), or put it on another plan: one of your contract's, whose limits and monthly quotas the account then has. Its tenant is partner:<partner>:<account>, an ordinary Slotlock tenant of its own. Repeating the call changes nothing more. 400 invalid_partner_plan for a plan your contract does not have.",
1627
+ method: 'PUT',
1628
+ path: '/v1/partner/accounts/{account}',
1629
+ input: z.object({ account: PARTNER_ACCOUNT, plan: PARTNER_PLAN_NAME }).strict(),
1630
+ output: z.object({ account: PARTNER_ACCOUNT_OUTPUT, created: z.boolean() }).strict(),
1631
+ risk: { readOnly: false, destructive: false, idempotent: true },
1632
+ invoke: 'partnerPutAccount',
1633
+ scope: 'partner',
1634
+ example: { account: 'user-4821', plan: 'partner_pro' },
1635
+ },
1636
+ {
1637
+ name: 'slotlock_partner_list_accounts',
1638
+ title: 'List accounts',
1639
+ description: "The partner's accounts, by id: plan, whether suspended, and when made and last changed.",
1640
+ method: 'GET',
1641
+ path: '/v1/partner/accounts',
1642
+ input: z.object({}).strict(),
1643
+ output: z.object({ accounts: z.array(PARTNER_ACCOUNT_OUTPUT).max(10000) }).strict(),
1644
+ risk: { readOnly: true, destructive: false, idempotent: true },
1645
+ invoke: 'partnerListAccounts',
1646
+ scope: 'partner',
1647
+ example: {},
1648
+ },
1649
+ {
1650
+ name: 'slotlock_partner_get_account',
1651
+ title: 'Read an account',
1652
+ description: 'One of the partner\'s accounts, with how many resources (archived ones included) and active API keys it holds. 404 account_not_found for an account the partner does not have.',
1653
+ method: 'GET',
1654
+ path: '/v1/partner/accounts/{account}',
1655
+ input: z.object({ account: PARTNER_ACCOUNT }).strict(),
1656
+ output: z.object({ account: PARTNER_ACCOUNT_OUTPUT }).strict(),
1657
+ risk: { readOnly: true, destructive: false, idempotent: true },
1658
+ invoke: 'partnerGetAccount',
1659
+ scope: 'partner',
1660
+ example: { account: 'user-4821' },
1661
+ },
1662
+ {
1663
+ name: 'slotlock_partner_list_keys',
1664
+ title: "List an account's keys",
1665
+ description: "The account's API keys, revoked and expired ones included, newest first; never the keys themselves.",
1666
+ method: 'GET',
1667
+ path: '/v1/partner/accounts/{account}/keys',
1668
+ input: z.object({ account: PARTNER_ACCOUNT, environment: PARTNER_ENVIRONMENT }).strict(),
1669
+ output: z.object({ keys: z.array(PARTNER_KEY_OUTPUT).max(1000) }).strict(),
1670
+ risk: { readOnly: true, destructive: false, idempotent: true },
1671
+ invoke: 'partnerListKeys',
1672
+ scope: 'partner',
1673
+ example: { account: 'user-4821' },
1674
+ },
1675
+ {
1676
+ name: 'slotlock_partner_create_key',
1677
+ title: 'Create a key for an account',
1678
+ description: "An API key for the account, shown once in key and never again; only its SHA-256 is kept. Give it to your user's calls: it acts in the account's tenant, over MCP, A2A and REST, as any key. environment: test makes it in the account's test environment instead (a tenant of its own, on the same plan, never billed; its keys start slk_test_). scopes: some of the ones your contract allows (read and write unless it says), by default all of them; expires_in_days: 1-3650, or none. limits hold the key's agent below the server's limits: max_holds live at once, max_held_hours they hold together, requests_per_minute, and requires_approval to make every booking it asks for wait for a person (the account's Approvals). 403 scope_not_in_contract for another scope; 409 api_key_limit_reached past the account plan's keys.",
1679
+ method: 'POST',
1680
+ path: '/v1/partner/accounts/{account}/keys',
1681
+ input: z
1682
+ .object({
1683
+ account: PARTNER_ACCOUNT,
1684
+ name: z.string().trim().min(1).max(100),
1685
+ scopes: z.array(z.enum(['read', 'write', 'admin'])).min(1).max(3).optional(),
1686
+ expires_in_days: z.number().int().min(1).max(3650).optional(),
1687
+ environment: PARTNER_ENVIRONMENT,
1688
+ limits: KEY_LIMITS.optional(),
1689
+ })
1690
+ .strict(),
1691
+ output: z.object({ key: ACCOUNT_KEY, api_key: PARTNER_KEY_OUTPUT }).strict(),
1692
+ risk: { readOnly: false, destructive: false, idempotent: false },
1693
+ invoke: 'partnerCreateKey',
1694
+ scope: 'partner',
1695
+ example: { account: 'user-4821', name: 'Booking agent' },
1696
+ },
1697
+ {
1698
+ name: 'slotlock_partner_rotate_key',
1699
+ title: "Rotate an account's key",
1700
+ description: 'A new secret for one of the account\'s active keys, shown once; the old one stops at once. The key keeps its id and what it booked. 404 key_not_found otherwise.',
1701
+ method: 'POST',
1702
+ path: '/v1/partner/accounts/{account}/keys/{key_id}/rotate',
1703
+ input: z.object({ account: PARTNER_ACCOUNT, key_id: z.string().uuid(), environment: PARTNER_ENVIRONMENT }).strict(),
1704
+ output: z.object({ key: ACCOUNT_KEY, api_key: PARTNER_KEY_OUTPUT }).strict(),
1705
+ risk: { readOnly: false, destructive: false, idempotent: false },
1706
+ invoke: 'partnerRotateKey',
1707
+ scope: 'partner',
1708
+ example: { account: 'user-4821', key_id: '7d1e4f8a-2b3c-4d5e-8f90-123456789abc' },
1709
+ },
1710
+ {
1711
+ name: 'slotlock_partner_revoke_key',
1712
+ title: "Revoke an account's key",
1713
+ description: "Revoke one of the account's keys at once; revoking it again changes nothing. 404 key_not_found otherwise.",
1714
+ method: 'DELETE',
1715
+ path: '/v1/partner/accounts/{account}/keys/{key_id}',
1716
+ input: z.object({ account: PARTNER_ACCOUNT, key_id: z.string().uuid(), environment: PARTNER_ENVIRONMENT }).strict(),
1717
+ output: z.object({ api_key: PARTNER_KEY_OUTPUT }).strict(),
1718
+ risk: { readOnly: false, destructive: true, idempotent: true },
1719
+ invoke: 'partnerRevokeKey',
1720
+ scope: 'partner',
1721
+ example: { account: 'user-4821', key_id: '7d1e4f8a-2b3c-4d5e-8f90-123456789abc' },
1722
+ },
1723
+ {
1724
+ name: 'slotlock_partner_suspend_account',
1725
+ title: 'Suspend an account',
1726
+ description: 'Suspend the account, as slotlock tenant suspend: it may read, and writes nothing; its keys get no new secret. suspended says whether it was not suspended already.',
1727
+ method: 'POST',
1728
+ path: '/v1/partner/accounts/{account}/suspend',
1729
+ input: z.object({ account: PARTNER_ACCOUNT, reason: z.string().trim().min(1).max(200).optional() }).strict(),
1730
+ output: z.object({ suspended: z.boolean() }).strict(),
1731
+ risk: { readOnly: false, destructive: false, idempotent: true },
1732
+ invoke: 'partnerSuspendAccount',
1733
+ scope: 'partner',
1734
+ example: { account: 'user-4821', reason: 'unpaid' },
1735
+ },
1736
+ {
1737
+ name: 'slotlock_partner_unsuspend_account',
1738
+ title: 'Lift a suspension',
1739
+ description: 'Lift a suspension the partner made. One Pylota made stays: unsuspended is false then, as when the account was not suspended.',
1740
+ method: 'POST',
1741
+ path: '/v1/partner/accounts/{account}/unsuspend',
1742
+ input: z.object({ account: PARTNER_ACCOUNT }).strict(),
1743
+ output: z.object({ unsuspended: z.boolean() }).strict(),
1744
+ risk: { readOnly: false, destructive: false, idempotent: true },
1745
+ invoke: 'partnerUnsuspendAccount',
1746
+ scope: 'partner',
1747
+ example: { account: 'user-4821' },
1748
+ },
1749
+ {
1750
+ name: 'slotlock_partner_erase_account',
1751
+ title: 'Erase an account',
1752
+ description: "Erase the account's tenant, every row it owns, as /v1/tenant/erase, and the partner's record of it. confirm repeats the account. Cannot be undone.",
1753
+ method: 'POST',
1754
+ path: '/v1/partner/accounts/{account}/erase',
1755
+ input: z.object({ account: PARTNER_ACCOUNT, confirm: PARTNER_ACCOUNT }).strict(),
1756
+ output: z.object({ erased: z.boolean(), rows: z.number().int().min(0) }).strict(),
1757
+ risk: { readOnly: false, destructive: true, idempotent: true },
1758
+ invoke: 'partnerEraseAccount',
1759
+ scope: 'partner',
1760
+ example: { account: 'user-4821', confirm: 'user-4821' },
1761
+ },
1762
+ {
1763
+ name: 'slotlock_partner_get_usage',
1764
+ title: "Read the accounts' usage",
1765
+ description: "The partner's accounts' usage, the counts Pylota invoices from, by account, UTC day and metric (operations, bookings, holds, webhook_deliveries, resources: each day's count), from one UTC day to another (inclusive, at most 400 days apart; by default this month); one account, or all. 400 invalid_usage_range otherwise.",
1766
+ method: 'GET',
1767
+ path: '/v1/partner/usage',
1768
+ input: z.object({ from: USAGE_DAY.optional(), to: USAGE_DAY.optional(), account: PARTNER_ACCOUNT.optional() }).strict(),
1769
+ output: z
1770
+ .object({
1771
+ from: USAGE_DAY,
1772
+ to: USAGE_DAY,
1773
+ usage: z
1774
+ .array(z.object({ account: PARTNER_ACCOUNT, day: USAGE_DAY, metric: z.string().min(1).max(40), count: USAGE_COUNT }).strict())
1775
+ .max(500000),
1776
+ })
1777
+ .strict(),
1778
+ risk: { readOnly: true, destructive: false, idempotent: true },
1779
+ invoke: 'partnerGetUsage',
1780
+ scope: 'partner',
1781
+ example: { from: '2026-11-01', to: '2026-11-30' },
1782
+ },
1783
+ {
1784
+ name: 'slotlock_partner_reconcile_usage',
1785
+ title: 'Compare your counts with ours',
1786
+ description: "Send your count of each account's operations (and, if you keep them, bookings) on one ended UTC day, as your daily cross-check: the answer gives ours beside yours for every account either of us counted, the difference in percent, and whether every one is within 0.5%. A day outside that is recorded as a mismatch, which Pylota looks into. Sending the same day again replaces its record.",
1787
+ method: 'POST',
1788
+ path: '/v1/partner/usage/reconcile',
1789
+ input: z
1790
+ .object({
1791
+ day: USAGE_DAY,
1792
+ accounts: z
1793
+ .array(z.object({ account: PARTNER_ACCOUNT, operations: USAGE_COUNT, bookings: USAGE_COUNT.optional() }).strict())
1794
+ .max(10000),
1795
+ })
1796
+ .strict(),
1797
+ output: z
1798
+ .object({
1799
+ day: USAGE_DAY,
1800
+ within_tolerance: z.boolean(),
1801
+ tolerance_percent: z.number(),
1802
+ accounts: z
1803
+ .array(z
1804
+ .object({
1805
+ account: PARTNER_ACCOUNT,
1806
+ metric: z.enum(['operations', 'bookings']),
1807
+ ours: USAGE_COUNT,
1808
+ theirs: USAGE_COUNT.nullable(),
1809
+ difference_percent: z.number().nullable(),
1810
+ })
1811
+ .strict())
1812
+ .max(20000),
1813
+ })
1814
+ .strict(),
1815
+ risk: { readOnly: false, destructive: false, idempotent: true },
1816
+ invoke: 'partnerReconcileUsage',
1817
+ scope: 'partner',
1818
+ example: { day: '2026-11-09', accounts: [{ account: 'user-4821', operations: 1204 }] },
1819
+ },
1009
1820
  ]);
1010
1821
  const BY_NAME = new Map(SLOTLOCK_APPLICATION_OPERATIONS.map((operation) => [operation.name, operation]));
1011
1822
  export function isSlotlockApplicationOperation(name) {