slotlock 0.5.1 → 0.5.3

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/ddl.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // the caller's search_path back at the end, so a caller that keeps going in the same transaction
12
12
  // resolves its own names as before. Apply it in one transaction: under autocommit every statement
13
13
  // is a transaction of its own, and the pin ends with the statement that sets it.
14
+ import { SLOTLOCK_CHANGE_ORIGINS, slotlockCoverageSourceIdSql } from './event-feed.js';
14
15
  import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
15
16
  const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
16
17
  const DEPLOYMENT_SEARCH_PATH_PIN = `SELECT pg_catalog.set_config('${CALLER_SEARCH_PATH_SETTING}', pg_catalog.current_setting('search_path'), true);
@@ -239,6 +240,7 @@ export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
239
240
  'tenant_suspensions',
240
241
  'webhook_tenants',
241
242
  'rate_buckets',
243
+ 'assistant_tenants',
242
244
  ]);
243
245
  /**
244
246
  * The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
@@ -251,6 +253,8 @@ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
251
253
  'slotlock.webhook_tenants(text)',
252
254
  'slotlock.unregister_webhook_tenant(text)',
253
255
  'slotlock.take_rate_token(text, double precision, double precision)',
256
+ 'slotlock.register_assistant_tenant(text, timestamptz)',
257
+ 'slotlock.sweep_assistant_conversations(integer)',
254
258
  ]);
255
259
  /**
256
260
  * Functions only the deployment role runs: suspending and lifting a suspension. No other role is
@@ -547,6 +551,55 @@ AS $slotlock_erase_api_keys$
547
551
  SELECT count(*) FROM erased;
548
552
  $slotlock_erase_api_keys$;
549
553
  REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[5]} FROM PUBLIC;
554
+
555
+ -- A key created, given a new secret or revoked appends to its tenant's feed (key.created,
556
+ -- key.rotated, key.revoked): its id, scopes and expiry, never its digest, prefix or name. Only the
557
+ -- functions above write keys, as the schema's owner, which forced row-level security on the feed
558
+ -- binds too: the row is appended under the tenant's name in every setting a policy reads, and the
559
+ -- caller's values are put back after. Who did it is the caller's actor, else the key's creator.
560
+ CREATE OR REPLACE FUNCTION slotlock.outbox_api_key_change()
561
+ RETURNS trigger
562
+ LANGUAGE plpgsql
563
+ SET search_path = pg_catalog, slotlock, pg_temp
564
+ AS $slotlock_outbox_api_key$
565
+ DECLARE
566
+ kind text;
567
+ setting text;
568
+ previous jsonb := '{}'::jsonb;
569
+ BEGIN
570
+ IF TG_OP = 'INSERT' THEN
571
+ kind := 'key.created';
572
+ ELSIF OLD.revoked_at IS NULL AND NEW.revoked_at IS NOT NULL THEN
573
+ kind := 'key.revoked';
574
+ ELSIF OLD.secret_hash IS DISTINCT FROM NEW.secret_hash THEN
575
+ kind := 'key.rotated';
576
+ ELSE
577
+ RETURN NULL;
578
+ END IF;
579
+ FOR setting IN
580
+ SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
581
+ LOOP
582
+ previous := previous || jsonb_build_object(setting, current_setting(setting, true));
583
+ PERFORM set_config(setting, NEW.tenant_ref, true);
584
+ END LOOP;
585
+ INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
586
+ VALUES (
587
+ NEW.tenant_ref, kind, NEW.id::text,
588
+ jsonb_build_object(
589
+ 'id', NEW.id, 'scopes', to_jsonb(NEW.scopes), 'expires_at', NEW.expires_at,
590
+ 'revoked_at', NEW.revoked_at),
591
+ COALESCE(NULLIF(current_setting('slotlock.actor', true), ''),
592
+ CASE WHEN TG_OP = 'INSERT' THEN NEW.created_by END)
593
+ );
594
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
595
+ PERFORM set_config(setting, COALESCE(previous ->> setting, ''), true);
596
+ END LOOP;
597
+ RETURN NULL;
598
+ END
599
+ $slotlock_outbox_api_key$;
600
+ CREATE OR REPLACE TRIGGER slotlock_outbox_api_key_change
601
+ AFTER INSERT OR UPDATE OF revoked_at, secret_hash ON slotlock.api_keys
602
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_api_key_change();
550
603
  `;
551
604
  // The dashboard's records (dashboard.ts), shared by every server on this database: a finished sign-in
552
605
  // (its callback works once), a sent form (it runs once) and a signed-out session (a copied cookie
@@ -872,6 +925,18 @@ BEGIN
872
925
  DELETE FROM slotlock.waitlist_entries WHERE tenant_ref = requested_tenant_ref;
873
926
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
874
927
  RETURN NEXT;
928
+ erased_table := 'booking_rule_defaults';
929
+ DELETE FROM slotlock.booking_rule_defaults WHERE tenant_ref = requested_tenant_ref;
930
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
931
+ RETURN NEXT;
932
+ erased_table := 'assistant_conversations';
933
+ DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
934
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
935
+ RETURN NEXT;
936
+ erased_table := 'assistant_settings';
937
+ DELETE FROM slotlock.assistant_settings WHERE tenant_ref = requested_tenant_ref;
938
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
939
+ RETURN NEXT;
875
940
  erased_table := 'resources';
876
941
  DELETE FROM slotlock.resources WHERE tenant_ref = requested_tenant_ref;
877
942
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
@@ -889,6 +954,10 @@ BEGIN
889
954
  DELETE FROM slotlock.webhook_endpoints WHERE tenant_ref = requested_tenant_ref;
890
955
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
891
956
  RETURN NEXT;
957
+ erased_table := 'assistant_tenants';
958
+ DELETE FROM slotlock.assistant_tenants WHERE tenant_ref = requested_tenant_ref;
959
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
960
+ RETURN NEXT;
892
961
  erased_table := 'webhook_tenants';
893
962
  DELETE FROM slotlock.webhook_tenants WHERE tenant_ref = requested_tenant_ref;
894
963
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
@@ -1015,6 +1084,106 @@ BEGIN
1015
1084
  END
1016
1085
  $slotlock_take_rate_token$;
1017
1086
  REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[5]} FROM PUBLIC;
1087
+
1088
+ -- The tenants with a dashboard assistant conversation, and when the first of them expires. Forced
1089
+ -- row-level security hides every other tenant's conversations, from the owner too, so this list
1090
+ -- (names and a time only) is how maintenance finds the tenants whose conversations have expired,
1091
+ -- whichever server stored them: one only the dashboard (or its Worker) ever used included. No role
1092
+ -- but its owner reads it.
1093
+ CREATE TABLE IF NOT EXISTS slotlock.assistant_tenants (
1094
+ tenant_ref text PRIMARY KEY,
1095
+ next_expiry timestamptz NOT NULL,
1096
+ CONSTRAINT slotlock_assistant_tenants_tenant_ref_valid
1097
+ CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
1098
+ );
1099
+ CREATE INDEX IF NOT EXISTS slotlock_assistant_tenants_expiry_idx
1100
+ ON slotlock.assistant_tenants (next_expiry);
1101
+ ALTER TABLE slotlock.assistant_tenants ENABLE ROW LEVEL SECURITY;
1102
+ REVOKE ALL ON slotlock.assistant_tenants FROM PUBLIC;
1103
+
1104
+ -- Record that the tenant has a conversation expiring at expires (the earliest is kept).
1105
+ CREATE OR REPLACE FUNCTION slotlock.register_assistant_tenant(requested_tenant_ref text, expires timestamptz)
1106
+ RETURNS void
1107
+ LANGUAGE plpgsql
1108
+ VOLATILE
1109
+ SECURITY DEFINER
1110
+ SET search_path = pg_catalog, slotlock, pg_temp
1111
+ AS $slotlock_register_assistant_tenant$
1112
+ BEGIN
1113
+ IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
1114
+ OR expires IS NULL THEN
1115
+ RAISE EXCEPTION 'Slotlock assistant tenant arguments are invalid' USING ERRCODE = 'invalid_parameter_value';
1116
+ END IF;
1117
+ INSERT INTO slotlock.assistant_tenants AS kept (tenant_ref, next_expiry)
1118
+ VALUES (requested_tenant_ref, expires)
1119
+ ON CONFLICT (tenant_ref) DO UPDATE SET next_expiry = LEAST(kept.next_expiry, EXCLUDED.next_expiry);
1120
+ END
1121
+ $slotlock_register_assistant_tenant$;
1122
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[6]} FROM PUBLIC;
1123
+
1124
+ -- Delete expired assistant conversations across every tenant: up to max_rows of them, from the
1125
+ -- tenants whose earliest expiry has passed, longest overdue first, each under its own tenant
1126
+ -- context (forced row-level security binds the owner too), putting the caller's settings back
1127
+ -- after. A tenant left with none leaves the list; one with more is listed at its next expiry.
1128
+ -- Only a count comes back.
1129
+ CREATE OR REPLACE FUNCTION slotlock.sweep_assistant_conversations(max_rows integer)
1130
+ RETURNS bigint
1131
+ LANGUAGE plpgsql
1132
+ VOLATILE
1133
+ SECURITY DEFINER
1134
+ SET search_path = pg_catalog, slotlock, pg_temp
1135
+ AS $slotlock_sweep_assistant_conversations$
1136
+ DECLARE
1137
+ setting text;
1138
+ previous jsonb := '{}'::jsonb;
1139
+ due record;
1140
+ deleted bigint := 0;
1141
+ step bigint;
1142
+ upcoming timestamptz;
1143
+ BEGIN
1144
+ IF max_rows IS NULL OR max_rows NOT BETWEEN 1 AND 100000 THEN
1145
+ RAISE EXCEPTION 'Slotlock assistant sweep size must be 1-100,000' USING ERRCODE = 'invalid_parameter_value';
1146
+ END IF;
1147
+ FOR setting IN
1148
+ SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
1149
+ LOOP
1150
+ previous := previous || jsonb_build_object(setting, current_setting(setting, true));
1151
+ END LOOP;
1152
+ FOR due IN
1153
+ SELECT listed.tenant_ref FROM slotlock.assistant_tenants listed
1154
+ WHERE listed.next_expiry <= now()
1155
+ ORDER BY listed.next_expiry
1156
+ LIMIT 1000
1157
+ FOR UPDATE SKIP LOCKED
1158
+ LOOP
1159
+ EXIT WHEN deleted >= max_rows;
1160
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
1161
+ PERFORM set_config(setting, due.tenant_ref, true);
1162
+ END LOOP;
1163
+ DELETE FROM slotlock.assistant_conversations gone
1164
+ WHERE gone.tenant_ref = due.tenant_ref
1165
+ AND gone.member_ref IN (
1166
+ SELECT expired.member_ref FROM slotlock.assistant_conversations expired
1167
+ WHERE expired.tenant_ref = due.tenant_ref AND expired.expires_at <= now()
1168
+ LIMIT max_rows - deleted)
1169
+ AND gone.expires_at <= now();
1170
+ GET DIAGNOSTICS step = ROW_COUNT;
1171
+ deleted := deleted + step;
1172
+ SELECT min(live.expires_at) INTO upcoming FROM slotlock.assistant_conversations live
1173
+ WHERE live.tenant_ref = due.tenant_ref;
1174
+ IF upcoming IS NULL THEN
1175
+ DELETE FROM slotlock.assistant_tenants WHERE tenant_ref = due.tenant_ref;
1176
+ ELSE
1177
+ UPDATE slotlock.assistant_tenants SET next_expiry = upcoming WHERE tenant_ref = due.tenant_ref;
1178
+ END IF;
1179
+ END LOOP;
1180
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
1181
+ PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
1182
+ END LOOP;
1183
+ RETURN deleted;
1184
+ END
1185
+ $slotlock_sweep_assistant_conversations$;
1186
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[7]} FROM PUBLIC;
1018
1187
  `;
1019
1188
  // A constraint an upgrade added NOT VALID (no table scan under its ACCESS EXCLUSIVE lock) is checked
1020
1189
  // here. applySchema runs this in a transaction of its own once the DDL has committed: inside the
@@ -1079,6 +1248,36 @@ CREATE TABLE IF NOT EXISTS slotlock.outbox (
1079
1248
  );
1080
1249
  -- Who made the change (withTenant's actor: an API key, an agent), for the history.
1081
1250
  ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS actor text;
1251
+ -- Where it came from (withTenant's origin: mcp, a2a, rest, application, dashboard,
1252
+ -- dashboard-assistant, maintenance, library) and the API key that made it (withTenant's keyId).
1253
+ -- Added bare, so the rows already there keep null (an earlier release) and nothing is scanned under
1254
+ -- the lock; the defaults then bind only new rows. A write that names no origin (code calling the
1255
+ -- store outside withTenant, or SQL of its own) is \`library\`.
1256
+ ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS origin text;
1257
+ ALTER TABLE slotlock.outbox ADD COLUMN IF NOT EXISTS key_id uuid;
1258
+ ALTER TABLE slotlock.outbox
1259
+ ALTER COLUMN origin SET DEFAULT COALESCE(NULLIF(current_setting('slotlock.origin', true), ''), 'library'),
1260
+ ALTER COLUMN key_id SET DEFAULT NULLIF(current_setting('slotlock.key_id', true), '')::uuid;
1261
+ -- The origins the API reads, and no other. NOT VALID, so adding it scans nothing under the lock;
1262
+ -- DEPLOYMENT_VALIDATE_CONSTRAINTS validates it after the migration commits. Its comment is the list
1263
+ -- it was built from: a release that changes the list replaces it, so an upgraded database never
1264
+ -- keeps refusing an origin the release writes.
1265
+ DO $slotlock_outbox_origin$
1266
+ BEGIN
1267
+ ALTER TABLE slotlock.outbox DROP CONSTRAINT IF EXISTS slotlock_outbox_origin_valid;
1268
+ IF (
1269
+ SELECT pg_catalog.obj_description(oid, 'pg_constraint') FROM pg_catalog.pg_constraint
1270
+ WHERE conrelid = 'slotlock.outbox'::regclass AND conname = 'slotlock_outbox_origin_known'
1271
+ ) IS DISTINCT FROM '${SLOTLOCK_CHANGE_ORIGINS.join(' ')}' THEN
1272
+ ALTER TABLE slotlock.outbox DROP CONSTRAINT IF EXISTS slotlock_outbox_origin_known;
1273
+ ALTER TABLE slotlock.outbox
1274
+ ADD CONSTRAINT slotlock_outbox_origin_known
1275
+ CHECK (origin IN (${SLOTLOCK_CHANGE_ORIGINS.map((origin) => `'${origin}'`).join(', ')})) NOT VALID;
1276
+ COMMENT ON CONSTRAINT slotlock_outbox_origin_known ON slotlock.outbox
1277
+ IS '${SLOTLOCK_CHANGE_ORIGINS.join(' ')}';
1278
+ END IF;
1279
+ END
1280
+ $slotlock_outbox_origin$;
1082
1281
  CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_resource_idx
1083
1282
  ON slotlock.outbox (tenant_ref, resource_id, id) WHERE resource_id IS NOT NULL;
1084
1283
  CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_subject_idx
@@ -1133,6 +1332,10 @@ $slotlock_outbox_mark_pruned$;
1133
1332
  CREATE OR REPLACE TRIGGER slotlock_outbox_mark_pruned
1134
1333
  AFTER DELETE ON slotlock.outbox REFERENCING OLD TABLE AS pruned
1135
1334
  FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_mark_pruned();
1335
+ -- Live updates poll each tenant's feed (change-watch.ts). A pre-release build woke readers with a
1336
+ -- notification trigger here, which made every outbox commit take the cluster-wide notify lock: drop it.
1337
+ DROP TRIGGER IF EXISTS slotlock_outbox_notify ON slotlock.outbox;
1338
+ DROP FUNCTION IF EXISTS slotlock.outbox_notify();
1136
1339
 
1137
1340
  CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
1138
1341
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1160,14 +1363,31 @@ CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
1160
1363
  AND octet_length(url) BETWEEN 1 AND 2048
1161
1364
  AND secret ~ '^whsec_[A-Za-z0-9+/]{43}=$'
1162
1365
  ),
1163
- CONSTRAINT slotlock_webhook_endpoints_types_valid CHECK (
1164
- event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 20
1366
+ CONSTRAINT slotlock_webhook_endpoints_type_count_valid CHECK (
1367
+ event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 64
1165
1368
  ),
1166
1369
  CONSTRAINT slotlock_webhook_endpoints_error_valid CHECK (
1167
1370
  last_error IS NULL OR char_length(last_error) <= 500
1168
1371
  )
1169
1372
  );
1170
1373
  ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS changes_pruned boolean NOT NULL DEFAULT false;
1374
+ -- 0.5 allowed a filter of at most 20 types, fewer than there are now. The wider CHECK admits every
1375
+ -- row the old one did, so it is added NOT VALID (no scan under the lock), and the old one dropped.
1376
+ DO $slotlock_webhook_type_count$
1377
+ BEGIN
1378
+ IF NOT EXISTS (
1379
+ SELECT 1 FROM pg_catalog.pg_constraint
1380
+ WHERE conrelid = 'slotlock.webhook_endpoints'::regclass
1381
+ AND conname = 'slotlock_webhook_endpoints_type_count_valid'
1382
+ ) THEN
1383
+ ALTER TABLE slotlock.webhook_endpoints
1384
+ ADD CONSTRAINT slotlock_webhook_endpoints_type_count_valid CHECK (
1385
+ event_types IS NULL OR cardinality(event_types) BETWEEN 1 AND 64
1386
+ ) NOT VALID;
1387
+ END IF;
1388
+ ALTER TABLE slotlock.webhook_endpoints DROP CONSTRAINT IF EXISTS slotlock_webhook_endpoints_types_valid;
1389
+ END
1390
+ $slotlock_webhook_type_count$;
1171
1391
  CREATE INDEX IF NOT EXISTS slotlock_webhook_endpoints_tenant_idx
1172
1392
  ON slotlock.webhook_endpoints (tenant_ref, created_at);
1173
1393
 
@@ -1206,6 +1426,71 @@ CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_queue_idx
1206
1426
  CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
1207
1427
  ON slotlock.waitlist_entries (tenant_ref, starts_at);
1208
1428
 
1429
+ -- Default booking rules (booking-rules.ts), for the tenant (pool_ref '') or for one of its pools:
1430
+ -- a resource's rules are the tenant's, overlaid by its pool's, overlaid by its own.
1431
+ CREATE TABLE IF NOT EXISTS slotlock.booking_rule_defaults (
1432
+ tenant_ref text NOT NULL,
1433
+ pool_ref text NOT NULL DEFAULT '',
1434
+ rules jsonb NOT NULL,
1435
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1436
+ CONSTRAINT slotlock_booking_rule_defaults_pkey PRIMARY KEY (tenant_ref, pool_ref),
1437
+ CONSTRAINT slotlock_booking_rule_defaults_identity_valid CHECK (
1438
+ octet_length(tenant_ref) BETWEEN 1 AND 500
1439
+ AND (pool_ref = '' OR pool_ref ~ '^[a-z0-9][a-z0-9._-]{0,99}$')
1440
+ ),
1441
+ CONSTRAINT slotlock_booking_rule_defaults_rules_valid CHECK (
1442
+ jsonb_typeof(rules) = 'object' AND octet_length(rules::text) <= 4096
1443
+ )
1444
+ );
1445
+
1446
+ -- The model a tenant's dashboard assistant uses (dashboard.ts, assistant.ts), one row a tenant.
1447
+ -- The API key is stored only sealed (AES-256-GCM under the deployment's SLOTLOCK_ASSISTANT_SECRET,
1448
+ -- bound to the tenant), with its last four characters to show.
1449
+ CREATE TABLE IF NOT EXISTS slotlock.assistant_settings (
1450
+ tenant_ref text PRIMARY KEY,
1451
+ provider text NOT NULL,
1452
+ base_url text NOT NULL,
1453
+ model text NOT NULL,
1454
+ sealed_key text,
1455
+ key_hint text,
1456
+ updated_by text,
1457
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1458
+ CONSTRAINT slotlock_assistant_settings_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
1459
+ CONSTRAINT slotlock_assistant_settings_provider_valid CHECK (provider IN ('anthropic', 'openai')),
1460
+ CONSTRAINT slotlock_assistant_settings_base_url_valid CHECK (octet_length(base_url) BETWEEN 1 AND 2048),
1461
+ CONSTRAINT slotlock_assistant_settings_model_valid CHECK (octet_length(model) BETWEEN 1 AND 200),
1462
+ CONSTRAINT slotlock_assistant_settings_key_valid CHECK (
1463
+ (sealed_key IS NULL) = (key_hint IS NULL)
1464
+ AND (sealed_key IS NULL OR octet_length(sealed_key) BETWEEN 1 AND 8192)
1465
+ AND (key_hint IS NULL OR char_length(key_hint) BETWEEN 1 AND 4)
1466
+ ),
1467
+ CONSTRAINT slotlock_assistant_settings_updated_by_valid
1468
+ CHECK (updated_by IS NULL OR octet_length(updated_by) BETWEEN 1 AND 200)
1469
+ );
1470
+
1471
+ -- One member's short-lived conversation with the dashboard assistant, and the writes it proposed
1472
+ -- (actions, by id, each pending until the member approves or dismisses it).
1473
+ CREATE TABLE IF NOT EXISTS slotlock.assistant_conversations (
1474
+ tenant_ref text NOT NULL,
1475
+ member_ref text NOT NULL,
1476
+ messages jsonb NOT NULL DEFAULT '[]'::jsonb,
1477
+ actions jsonb NOT NULL DEFAULT '{}'::jsonb,
1478
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1479
+ expires_at timestamptz NOT NULL,
1480
+ CONSTRAINT slotlock_assistant_conversations_pkey PRIMARY KEY (tenant_ref, member_ref),
1481
+ CONSTRAINT slotlock_assistant_conversations_identity_valid CHECK (
1482
+ octet_length(tenant_ref) BETWEEN 1 AND 500 AND octet_length(member_ref) BETWEEN 1 AND 500
1483
+ ),
1484
+ CONSTRAINT slotlock_assistant_conversations_messages_valid CHECK (
1485
+ jsonb_typeof(messages) = 'array' AND octet_length(messages::text) <= 262144
1486
+ ),
1487
+ CONSTRAINT slotlock_assistant_conversations_actions_valid CHECK (
1488
+ jsonb_typeof(actions) = 'object' AND octet_length(actions::text) <= 131072
1489
+ )
1490
+ );
1491
+ CREATE INDEX IF NOT EXISTS slotlock_assistant_conversations_expiry_idx
1492
+ ON slotlock.assistant_conversations (tenant_ref, expires_at);
1493
+
1209
1494
  -- The tenant a reservation row belongs to: its own, or (a hold) its resource's.
1210
1495
  CREATE OR REPLACE FUNCTION slotlock.outbox_reservation_change()
1211
1496
  RETURNS trigger
@@ -1380,6 +1665,163 @@ ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error text;
1380
1665
  ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error_at timestamptz;
1381
1666
  ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS stale_reported_at timestamptz;
1382
1667
 
1668
+ -- Configuration changes (RFC 0002 P1-6): a resource's own settings, the sources its coverage is
1669
+ -- read from, and the tenant's webhook endpoints. Each change that alters something appends one row,
1670
+ -- with what it was before and after for the fields it changed; writing the same value again appends
1671
+ -- none. Erasure is not reported, as above.
1672
+ CREATE OR REPLACE FUNCTION slotlock.outbox_erasing(owner text)
1673
+ RETURNS boolean
1674
+ LANGUAGE sql
1675
+ STABLE
1676
+ SET search_path = pg_catalog, slotlock, pg_temp
1677
+ AS $slotlock_outbox_erasing$
1678
+ SELECT owner = current_setting('slotlock.erasing', true)
1679
+ AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
1680
+ WHERE nspname = 'slotlock');
1681
+ $slotlock_outbox_erasing$;
1682
+
1683
+ CREATE OR REPLACE FUNCTION slotlock.outbox_resource_change()
1684
+ RETURNS trigger
1685
+ LANGUAGE plpgsql
1686
+ SET search_path = pg_catalog, slotlock, pg_temp
1687
+ AS $slotlock_outbox_resource$
1688
+ DECLARE
1689
+ changed slotlock.resources%ROWTYPE;
1690
+ kind text;
1691
+ before jsonb := '{}'::jsonb;
1692
+ after jsonb := '{}'::jsonb;
1693
+ details jsonb;
1694
+ BEGIN
1695
+ changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1696
+ IF changed.tenant_ref IS NULL OR slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
1697
+ IF TG_OP = 'INSERT' THEN
1698
+ kind := 'resource.created';
1699
+ details := jsonb_build_object(
1700
+ 'timezone', NEW.timezone, 'visibility', NEW.visibility, 'pool', NEW.pool_ref,
1701
+ 'availability', NEW.availability_rules, 'booking_rules', NEW.booking_rules);
1702
+ ELSIF TG_OP = 'DELETE' THEN
1703
+ kind := 'resource.deleted';
1704
+ details := '{}'::jsonb;
1705
+ ELSIF (OLD.archived_at IS NULL) <> (NEW.archived_at IS NULL) THEN
1706
+ kind := CASE WHEN NEW.archived_at IS NULL THEN 'resource.restored' ELSE 'resource.archived' END;
1707
+ details := jsonb_build_object('archived_at', NEW.archived_at);
1708
+ ELSE
1709
+ -- Only what changed, under the names the API gives it; a null is kept (no pool, no rules).
1710
+ IF OLD.external_ref IS DISTINCT FROM NEW.external_ref THEN
1711
+ before := before || jsonb_build_object('reference', OLD.external_ref);
1712
+ after := after || jsonb_build_object('reference', NEW.external_ref);
1713
+ END IF;
1714
+ IF OLD.timezone IS DISTINCT FROM NEW.timezone THEN
1715
+ before := before || jsonb_build_object('timezone', OLD.timezone);
1716
+ after := after || jsonb_build_object('timezone', NEW.timezone);
1717
+ END IF;
1718
+ IF OLD.pool_ref IS DISTINCT FROM NEW.pool_ref THEN
1719
+ before := before || jsonb_build_object('pool', OLD.pool_ref);
1720
+ after := after || jsonb_build_object('pool', NEW.pool_ref);
1721
+ END IF;
1722
+ IF OLD.availability_rules IS DISTINCT FROM NEW.availability_rules THEN
1723
+ before := before || jsonb_build_object('availability', OLD.availability_rules);
1724
+ after := after || jsonb_build_object('availability', NEW.availability_rules);
1725
+ END IF;
1726
+ IF OLD.booking_rules IS DISTINCT FROM NEW.booking_rules THEN
1727
+ before := before || jsonb_build_object('booking_rules', OLD.booking_rules);
1728
+ after := after || jsonb_build_object('booking_rules', NEW.booking_rules);
1729
+ END IF;
1730
+ IF after = '{}'::jsonb THEN RETURN NULL; END IF;
1731
+ kind := 'resource.updated';
1732
+ details := jsonb_build_object('before', before, 'after', after);
1733
+ END IF;
1734
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
1735
+ VALUES (
1736
+ changed.tenant_ref, changed.id, kind, changed.id::text,
1737
+ jsonb_build_object('id', changed.id, 'reference', changed.external_ref) || details,
1738
+ NULLIF(current_setting('slotlock.actor', true), '')
1739
+ );
1740
+ RETURN NULL;
1741
+ END
1742
+ $slotlock_outbox_resource$;
1743
+
1744
+ -- A source first recorded, and deleted. Reading it again (a new revision) is progress, not a change
1745
+ -- of what the resource is read from, and appends nothing; neither does a resource's deletion, which
1746
+ -- takes its coverage with it and is its own change. The source is named by its digest (source_id):
1747
+ -- it can be an iCalendar URL with a token in it, or an address.
1748
+ CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_change()
1749
+ RETURNS trigger
1750
+ LANGUAGE plpgsql
1751
+ SET search_path = pg_catalog, slotlock, pg_temp
1752
+ AS $slotlock_outbox_coverage$
1753
+ DECLARE
1754
+ changed slotlock.calendar_coverage%ROWTYPE;
1755
+ BEGIN
1756
+ changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1757
+ IF slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
1758
+ IF TG_OP = 'DELETE'
1759
+ AND NOT EXISTS (SELECT 1 FROM slotlock.resources resource WHERE resource.id = OLD.resource_id) THEN
1760
+ RETURN NULL;
1761
+ END IF;
1762
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
1763
+ VALUES (
1764
+ changed.tenant_ref, changed.resource_id,
1765
+ CASE WHEN TG_OP = 'DELETE' THEN 'coverage.deleted' ELSE 'coverage.recorded' END,
1766
+ ${slotlockCoverageSourceIdSql('changed.tenant_ref', 'changed.source')},
1767
+ jsonb_strip_nulls(jsonb_build_object(
1768
+ 'resource_id', changed.resource_id,
1769
+ 'source_id', ${slotlockCoverageSourceIdSql('changed.tenant_ref', 'changed.source')},
1770
+ 'start', CASE WHEN TG_OP = 'INSERT' THEN changed.starts_at END,
1771
+ 'end', CASE WHEN TG_OP = 'INSERT' THEN changed.ends_at END,
1772
+ 'revision', CASE WHEN TG_OP = 'INSERT' THEN changed.revision END)),
1773
+ NULLIF(current_setting('slotlock.actor', true), '')
1774
+ );
1775
+ RETURN NULL;
1776
+ END
1777
+ $slotlock_outbox_coverage$;
1778
+
1779
+ -- An endpoint added, enabled again or deleted (disabling is webhook.disabled, from the delivery that
1780
+ -- gave up). Its id and the types it takes, never its URL, which can carry a token, or its secret.
1781
+ CREATE OR REPLACE FUNCTION slotlock.outbox_webhook_change()
1782
+ RETURNS trigger
1783
+ LANGUAGE plpgsql
1784
+ SET search_path = pg_catalog, slotlock, pg_temp
1785
+ AS $slotlock_outbox_webhook$
1786
+ DECLARE
1787
+ changed slotlock.webhook_endpoints%ROWTYPE;
1788
+ kind text;
1789
+ BEGIN
1790
+ changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1791
+ IF slotlock.outbox_erasing(changed.tenant_ref) THEN RETURN NULL; END IF;
1792
+ IF TG_OP = 'INSERT' THEN
1793
+ kind := 'webhook.created';
1794
+ ELSIF TG_OP = 'DELETE' THEN
1795
+ kind := 'webhook.deleted';
1796
+ ELSIF OLD.disabled_at IS NOT NULL AND NEW.disabled_at IS NULL THEN
1797
+ kind := 'webhook.enabled';
1798
+ ELSE
1799
+ RETURN NULL;
1800
+ END IF;
1801
+ INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
1802
+ VALUES (
1803
+ changed.tenant_ref, kind, changed.id::text,
1804
+ jsonb_build_object('id', changed.id, 'event_types', to_jsonb(changed.event_types)),
1805
+ NULLIF(current_setting('slotlock.actor', true), '')
1806
+ );
1807
+ RETURN NULL;
1808
+ END
1809
+ $slotlock_outbox_webhook$;
1810
+
1811
+ CREATE OR REPLACE TRIGGER slotlock_outbox_resource_change
1812
+ AFTER INSERT OR UPDATE OR DELETE ON slotlock.resources
1813
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_resource_change();
1814
+ CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_change
1815
+ AFTER INSERT OR DELETE ON slotlock.calendar_coverage
1816
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_coverage_change();
1817
+ CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
1818
+ AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
1819
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
1820
+ -- Only an update that sets disabled_at: leases and deliveries, which do not, cost nothing here.
1821
+ CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_enabled
1822
+ AFTER UPDATE OF disabled_at ON slotlock.webhook_endpoints
1823
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
1824
+
1383
1825
  DROP TRIGGER IF EXISTS slotlock_outbox_reservation_change ON slotlock.reservations;
1384
1826
  CREATE TRIGGER slotlock_outbox_reservation_change
1385
1827
  AFTER INSERT OR UPDATE OR DELETE ON slotlock.reservations
@@ -1421,6 +1863,8 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
1421
1863
  ends_at timestamptz NOT NULL,
1422
1864
  buffer_after_ms bigint NOT NULL DEFAULT 0,
1423
1865
  occupied_ends_at timestamptz,
1866
+ buffer_before_ms bigint NOT NULL DEFAULT 0,
1867
+ occupied_starts_at timestamptz,
1424
1868
  revision bigint NOT NULL DEFAULT 1,
1425
1869
  status text NOT NULL DEFAULT 'confirmed',
1426
1870
  expires_at timestamptz,
@@ -1441,6 +1885,15 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
1441
1885
  AND occupied_ends_at = ends_at + buffer_after_ms * interval '1 millisecond'
1442
1886
  )
1443
1887
  ),
1888
+ CONSTRAINT slotlock_reservations_buffer_before_valid CHECK (
1889
+ buffer_before_ms >= 0 AND buffer_before_ms <= 86400000
1890
+ ),
1891
+ -- NULL only without a leading buffer: a NULL occupied start reads as the start itself.
1892
+ CONSTRAINT slotlock_reservations_occupied_start_valid CHECK (
1893
+ (occupied_starts_at IS NULL AND buffer_before_ms = 0)
1894
+ OR (occupied_starts_at IS NOT NULL
1895
+ AND occupied_starts_at = starts_at - buffer_before_ms * interval '1 millisecond')
1896
+ ),
1444
1897
  CONSTRAINT slotlock_reservations_status_valid CHECK (status IN ('held', 'confirmed')),
1445
1898
  -- A hold must carry its expiry; a confirmed reservation must not (expiry is hold-only state).
1446
1899
  CONSTRAINT slotlock_reservations_expiry_shape CHECK (
@@ -1449,7 +1902,7 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
1449
1902
  ),
1450
1903
  CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
1451
1904
  resource_id WITH =,
1452
- tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
1905
+ tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
1453
1906
  )
1454
1907
  );
1455
1908
 
@@ -1481,6 +1934,11 @@ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
1481
1934
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
1482
1935
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
1483
1936
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_ends_at timestamptz;
1937
+ -- The leading buffer a booking keeps before its start (its resource's bufferBeforeMinutes when it
1938
+ -- was made), and where its occupied time starts: its start less that buffer. A row written before
1939
+ -- leading buffers has none, so its occupied time starts at its start (NULL reads as starts_at).
1940
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_before_ms bigint NOT NULL DEFAULT 0;
1941
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_starts_at timestamptz;
1484
1942
  -- A keyed hold's holder (its principal) and the group of alternatives it was taken in: an agent
1485
1943
  -- releases or books only its own holds, and booking one releases the rest of its group.
1486
1944
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS holder_ref text;
@@ -1518,6 +1976,31 @@ BEGIN
1518
1976
  END IF;
1519
1977
  END $$;
1520
1978
 
1979
+ -- The occupied start, materialized for the same reason as the occupied end.
1980
+ CREATE OR REPLACE FUNCTION slotlock.set_reservation_occupied_start()
1981
+ RETURNS trigger
1982
+ LANGUAGE plpgsql
1983
+ SET search_path = pg_catalog, slotlock, pg_temp
1984
+ AS $$
1985
+ BEGIN
1986
+ NEW.occupied_starts_at := NEW.starts_at - NEW.buffer_before_ms * interval '1 millisecond';
1987
+ RETURN NEW;
1988
+ END $$;
1989
+
1990
+ DO $$
1991
+ BEGIN
1992
+ IF NOT EXISTS (
1993
+ SELECT 1 FROM pg_trigger
1994
+ WHERE tgname = 'slotlock_reservations_set_occupied_start'
1995
+ AND tgrelid = 'slotlock.reservations'::regclass
1996
+ AND NOT tgisinternal
1997
+ ) THEN
1998
+ CREATE TRIGGER slotlock_reservations_set_occupied_start
1999
+ BEFORE INSERT OR UPDATE OF starts_at, buffer_before_ms ON slotlock.reservations
2000
+ FOR EACH ROW EXECUTE FUNCTION slotlock.set_reservation_occupied_start();
2001
+ END IF;
2002
+ END $$;
2003
+
1521
2004
  -- Cancellation is an idempotency fact, not the absence of a reservation. Keeping a tombstone
1522
2005
  -- prevents delayed at-least-once create delivery from resurrecting a cancelled booking key.
1523
2006
  CREATE TABLE IF NOT EXISTS slotlock.reservation_tombstones (
@@ -1812,6 +2295,34 @@ BEGIN
1812
2295
  buffer_after_ms >= 0 AND buffer_after_ms <= 2592000000
1813
2296
  ) NOT VALID;
1814
2297
  END IF;
2298
+ -- Every existing row has no leading buffer, so both hold of it; added NOT VALID (no scan under
2299
+ -- the lock) and validated after the migration commits.
2300
+ IF NOT EXISTS (
2301
+ SELECT 1 FROM pg_constraint
2302
+ WHERE conname = 'slotlock_reservations_buffer_before_valid'
2303
+ AND conrelid = 'slotlock.reservations'::regclass
2304
+ ) THEN
2305
+ ALTER TABLE slotlock.reservations
2306
+ ADD CONSTRAINT slotlock_reservations_buffer_before_valid CHECK (
2307
+ buffer_before_ms >= 0 AND buffer_before_ms <= 86400000
2308
+ ) NOT VALID;
2309
+ END IF;
2310
+ -- A first form of this check let a NULL occupied start through with a leading buffer (the
2311
+ -- comparison is NULL, which a CHECK passes): replaced wherever it lacks the IS NOT NULL guard.
2312
+ IF NOT EXISTS (
2313
+ SELECT 1 FROM pg_constraint
2314
+ WHERE conname = 'slotlock_reservations_occupied_start_valid'
2315
+ AND conrelid = 'slotlock.reservations'::regclass
2316
+ AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_starts_at IS NOT NULL%'
2317
+ ) THEN
2318
+ ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_occupied_start_valid;
2319
+ ALTER TABLE slotlock.reservations
2320
+ ADD CONSTRAINT slotlock_reservations_occupied_start_valid CHECK (
2321
+ (occupied_starts_at IS NULL AND buffer_before_ms = 0)
2322
+ OR (occupied_starts_at IS NOT NULL
2323
+ AND occupied_starts_at = starts_at - buffer_before_ms * interval '1 millisecond')
2324
+ ) NOT VALID;
2325
+ END IF;
1815
2326
  END $$;
1816
2327
 
1817
2328
  -- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
@@ -1862,13 +2373,35 @@ BEGIN
1862
2373
  ALTER TABLE slotlock.reservations
1863
2374
  ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
1864
2375
  resource_id WITH =,
1865
- tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
2376
+ tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
1866
2377
  );
1867
2378
  END IF;
1868
2379
  ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_buffered_no_overlap;
1869
2380
  END
1870
2381
  $slotlock_occupied_overlap$;
1871
2382
 
2383
+ -- A row occupies from its occupied start too: its start less its leading buffer. Every row an
2384
+ -- upgrade finds was written before leading buffers and keeps none, so its range is the one the old
2385
+ -- constraint already kept apart and none can conflict: the constraint is replaced in place, its
2386
+ -- index built once under the migration's lock.
2387
+ DO $slotlock_occupied_start_overlap$
2388
+ BEGIN
2389
+ IF NOT EXISTS (
2390
+ SELECT 1 FROM pg_constraint
2391
+ WHERE conname = 'slotlock_reservations_no_overlap'
2392
+ AND conrelid = 'slotlock.reservations'::regclass
2393
+ AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_starts_at%'
2394
+ ) THEN
2395
+ ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_no_overlap;
2396
+ ALTER TABLE slotlock.reservations
2397
+ ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
2398
+ resource_id WITH =,
2399
+ tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
2400
+ );
2401
+ END IF;
2402
+ END
2403
+ $slotlock_occupied_start_overlap$;
2404
+
1872
2405
  CREATE INDEX IF NOT EXISTS slotlock_reservations_resource_time_idx
1873
2406
  ON slotlock.reservations (resource_id, starts_at, ends_at);
1874
2407
 
@@ -2068,6 +2601,10 @@ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_ends_by
2068
2601
  ALTER TABLE slotlock.calendar_events
2069
2602
  ADD COLUMN IF NOT EXISTS buffer_after_ms integer NOT NULL DEFAULT 0
2070
2603
  CONSTRAINT slotlock_calendar_events_buffer_valid CHECK (buffer_after_ms BETWEEN 0 AND 86400000);
2604
+ -- The leading buffer a booked event keeps before each occurrence, kept for a later roll alike.
2605
+ ALTER TABLE slotlock.calendar_events
2606
+ ADD COLUMN IF NOT EXISTS buffer_before_ms integer NOT NULL DEFAULT 0
2607
+ CONSTRAINT slotlock_calendar_events_buffer_before_valid CHECK (buffer_before_ms BETWEEN 0 AND 86400000);
2071
2608
  ALTER TABLE slotlock.calendar_events
2072
2609
  DROP CONSTRAINT IF EXISTS slotlock_calendar_events_recurrence_cursor_bounded;
2073
2610
  DO $slotlock_calendar_event_recurrence_cursor$
@@ -2633,6 +3170,9 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
2633
3170
  'outbox_pruned',
2634
3171
  'webhook_endpoints',
2635
3172
  'waitlist_entries',
3173
+ 'booking_rule_defaults',
3174
+ 'assistant_settings',
3175
+ 'assistant_conversations',
2636
3176
  ]);
2637
3177
  const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
2638
3178
  const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
@@ -2702,6 +3242,12 @@ ALTER TABLE slotlock.webhook_endpoints ENABLE ROW LEVEL SECURITY;
2702
3242
  ALTER TABLE slotlock.webhook_endpoints FORCE ROW LEVEL SECURITY;
2703
3243
  ALTER TABLE slotlock.waitlist_entries ENABLE ROW LEVEL SECURITY;
2704
3244
  ALTER TABLE slotlock.waitlist_entries FORCE ROW LEVEL SECURITY;
3245
+ ALTER TABLE slotlock.booking_rule_defaults ENABLE ROW LEVEL SECURITY;
3246
+ ALTER TABLE slotlock.booking_rule_defaults FORCE ROW LEVEL SECURITY;
3247
+ ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
3248
+ ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
3249
+ ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
3250
+ ALTER TABLE slotlock.assistant_conversations FORCE ROW LEVEL SECURITY;
2705
3251
 
2706
3252
  -- One database must have one tenant-context contract. Re-applying with an adapter-specific
2707
3253
  -- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
@@ -2730,7 +3276,10 @@ BEGIN
2730
3276
  'outbox',
2731
3277
  'outbox_pruned',
2732
3278
  'webhook_endpoints',
2733
- 'waitlist_entries'
3279
+ 'waitlist_entries',
3280
+ 'booking_rule_defaults',
3281
+ 'assistant_settings',
3282
+ 'assistant_conversations'
2734
3283
  )
2735
3284
  LOOP
2736
3285
  EXECUTE format(
@@ -2941,6 +3490,36 @@ BEGIN
2941
3490
  USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
2942
3491
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
2943
3492
  END IF;
3493
+
3494
+ IF NOT EXISTS (
3495
+ SELECT 1 FROM pg_policies
3496
+ WHERE schemaname = 'slotlock' AND tablename = 'booking_rule_defaults'
3497
+ AND policyname = 'tenant_isolation_v1'
3498
+ ) THEN
3499
+ CREATE POLICY tenant_isolation_v1 ON slotlock.booking_rule_defaults
3500
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
3501
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
3502
+ END IF;
3503
+
3504
+ IF NOT EXISTS (
3505
+ SELECT 1 FROM pg_policies
3506
+ WHERE schemaname = 'slotlock' AND tablename = 'assistant_settings'
3507
+ AND policyname = 'tenant_isolation_v1'
3508
+ ) THEN
3509
+ CREATE POLICY tenant_isolation_v1 ON slotlock.assistant_settings
3510
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
3511
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
3512
+ END IF;
3513
+
3514
+ IF NOT EXISTS (
3515
+ SELECT 1 FROM pg_policies
3516
+ WHERE schemaname = 'slotlock' AND tablename = 'assistant_conversations'
3517
+ AND policyname = 'tenant_isolation_v1'
3518
+ ) THEN
3519
+ CREATE POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
3520
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
3521
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
3522
+ END IF;
2944
3523
  END
2945
3524
  $slotlock_rls$;
2946
3525
 
@@ -2978,6 +3557,12 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.webhook_endpoints
2978
3557
  IS 'slotlock:tenant-context:${tenantContextSetting}';
2979
3558
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.waitlist_entries
2980
3559
  IS 'slotlock:tenant-context:${tenantContextSetting}';
3560
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.booking_rule_defaults
3561
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
3562
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
3563
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
3564
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
3565
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
2981
3566
 
2982
3567
  INSERT INTO slotlock.rls_policy_contracts (
2983
3568
  table_name, tenant_context_setting, policy_fingerprint, installed_at
@@ -3017,7 +3602,10 @@ WHERE schemaname = 'slotlock'
3017
3602
  'outbox',
3018
3603
  'outbox_pruned',
3019
3604
  'webhook_endpoints',
3020
- 'waitlist_entries'
3605
+ 'waitlist_entries',
3606
+ 'booking_rule_defaults',
3607
+ 'assistant_settings',
3608
+ 'assistant_conversations'
3021
3609
  )
3022
3610
  AND policyname = 'tenant_isolation_v1'
3023
3611
  ON CONFLICT (table_name) DO UPDATE