borgmcp-server 0.1.4 → 0.1.7

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 (43) hide show
  1. package/README.md +32 -11
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/coordination-api.d.ts +2 -1
  4. package/dist/coordination-api.js +298 -89
  5. package/dist/coordination-api.js.map +1 -1
  6. package/dist/credentials.d.ts +4 -6
  7. package/dist/credentials.js +43 -19
  8. package/dist/credentials.js.map +1 -1
  9. package/dist/debug-log.d.ts +2 -3
  10. package/dist/debug-log.js +2 -3
  11. package/dist/debug-log.js.map +1 -1
  12. package/dist/enrollment.d.ts +3 -11
  13. package/dist/enrollment.js +21 -60
  14. package/dist/enrollment.js.map +1 -1
  15. package/dist/https-server.d.ts +2 -20
  16. package/dist/https-server.js +33 -51
  17. package/dist/https-server.js.map +1 -1
  18. package/dist/message-taxonomy.d.ts +38 -0
  19. package/dist/message-taxonomy.js +218 -0
  20. package/dist/message-taxonomy.js.map +1 -0
  21. package/dist/migrations.js +28 -0
  22. package/dist/migrations.js.map +1 -1
  23. package/dist/service.d.ts +4 -1
  24. package/dist/service.js +25 -10
  25. package/dist/service.js.map +1 -1
  26. package/dist/store.d.ts +66 -8
  27. package/dist/store.js +525 -100
  28. package/dist/store.js.map +1 -1
  29. package/npm-shrinkwrap.json +6 -7
  30. package/package.json +2 -2
  31. package/src/coordination-api.ts +312 -86
  32. package/src/credentials.ts +44 -26
  33. package/src/debug-log.ts +5 -5
  34. package/src/enrollment.ts +32 -78
  35. package/src/https-server.ts +44 -82
  36. package/src/message-taxonomy.ts +284 -0
  37. package/src/migrations.ts +28 -0
  38. package/src/service.ts +29 -9
  39. package/src/store.ts +593 -121
  40. package/dist/protocol-draft.d.ts +0 -2
  41. package/dist/protocol-draft.js +0 -31
  42. package/dist/protocol-draft.js.map +0 -1
  43. package/src/protocol-draft.ts +0 -32
package/src/store.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { statfsSync, statSync } from "node:fs";
3
3
  import { chmod, lstat, mkdir, open } from "node:fs/promises";
4
4
  import { dirname, join, parse, relative, resolve, sep } from "node:path";
5
5
  import { DatabaseSync } from "node:sqlite";
6
+ import type { MessageTaxonomy } from "borgmcp-shared/domain";
6
7
 
7
8
  import { applyMigrations, assertMigrationsCurrent } from "./migrations.js";
8
9
  import { operatorErrors } from "./operator-error.js";
@@ -12,6 +13,7 @@ import {
12
13
  type Principal,
13
14
  } from "./principal.js";
14
15
  import { patchRoleSectionText, type RoleSectionPatchOp } from "./role-section.js";
16
+ import { validateMessageTaxonomy } from "./message-taxonomy.js";
15
17
 
16
18
  export type CubeAccess = "read" | "write" | "manage";
17
19
 
@@ -20,6 +22,7 @@ export interface CubeRecord {
20
22
  readonly ownerId: string;
21
23
  readonly name: string;
22
24
  readonly directive: string;
25
+ readonly messageTaxonomy: MessageTaxonomy | null;
23
26
  readonly createdAt: string;
24
27
  readonly updatedAt: string;
25
28
  }
@@ -51,6 +54,22 @@ export interface EnrichedActivityRecord {
51
54
  readonly recipient_drone_ids: string[];
52
55
  }
53
56
 
57
+ export type ActivityNotificationRecord = EnrichedActivityRecord & (
58
+ | {
59
+ readonly kind: "ack" | "claim";
60
+ readonly log_entry_id: string;
61
+ readonly ack_kind: "ack" | "claim";
62
+ readonly ack_at: string;
63
+ readonly entry_preview: string;
64
+ readonly author_drone_id?: string;
65
+ readonly claimant_drone_id?: string;
66
+ readonly claimant_role?: string | null;
67
+ }
68
+ | { readonly kind: "heartbeat_ping" }
69
+ );
70
+
71
+ export type ActivityStreamRecord = EnrichedActivityRecord | ActivityNotificationRecord;
72
+
54
73
  export interface ClaimRecord {
55
74
  readonly log_entry_id: string;
56
75
  readonly claimant_drone_id: string;
@@ -104,6 +123,8 @@ export interface DroneRecord {
104
123
  readonly hostname: string | null;
105
124
  readonly posture: "observer" | "participant";
106
125
  readonly created_at: string;
126
+ readonly last_log_post_at?: string | null;
127
+ readonly seen_since?: boolean;
107
128
  }
108
129
 
109
130
  export interface StoreDiagnostics {
@@ -153,10 +174,19 @@ export interface StoreRuntime {
153
174
  readonly forPrincipal: (principal: Principal) => ScopedStore;
154
175
  readonly maintenance: MaintenanceStore;
155
176
  readonly credentials: CredentialStore;
177
+ readonly liveness: LivenessStore;
156
178
  readonly diagnostics: () => StoreDiagnostics;
157
179
  readonly close: () => void;
158
180
  }
159
181
 
182
+ export interface LivenessStore {
183
+ readonly scan: (options?: {
184
+ readonly silentMs?: number;
185
+ readonly cooldownMs?: number;
186
+ readonly limit?: number;
187
+ }) => ActivityNotificationRecord[];
188
+ }
189
+
160
190
  export interface DigestPair {
161
191
  readonly lookup: Buffer;
162
192
  readonly verifier: Buffer;
@@ -206,6 +236,7 @@ export interface StoredDroneSessionDigest extends StoredSecretDigest {
206
236
  readonly cubeId: string;
207
237
  readonly droneId: string;
208
238
  readonly expiresAt: string;
239
+ readonly evictedAt: string | null;
209
240
  }
210
241
 
211
242
  export interface CredentialStore {
@@ -232,6 +263,7 @@ export interface CredentialStore {
232
263
  readonly clientExists: (clientId: string) => boolean;
233
264
  readonly clientIsActive: (clientId: string) => boolean;
234
265
  readonly findDroneSessionCredential: (lookup: Buffer) => StoredDroneSessionDigest | null;
266
+ readonly findActiveDroneSessionExpiry: (sessionId: string) => string | null;
235
267
  readonly rotateClientCredential: (input: {
236
268
  readonly clientId: string;
237
269
  readonly credentialId: string;
@@ -244,6 +276,7 @@ export interface ScopedStore {
244
276
  readonly createCube: (input: CreateCubeInput) => CreateCubeRecord;
245
277
  readonly listCubes: () => CubeRecord[];
246
278
  readonly getCube: (cubeId: string) => CubeRecord | null;
279
+ readonly updateCube: (cubeId: string, input: UpdateCubeInput) => CubeRecord;
247
280
  readonly updateDirective: (cubeId: string, directive: string) => void;
248
281
  readonly appendActivity: (cubeId: string, message: string) => ActivityRecord;
249
282
  readonly readActivity: (cubeId: string, limit: number) => ActivityRecord[];
@@ -252,6 +285,12 @@ export interface ScopedStore {
252
285
  readonly updateRole: (cubeId: string, roleId: string, input: UpdateRoleInput) => RoleRecord;
253
286
  readonly patchRoleSection: (cubeId: string, roleId: string, input: RoleSectionPatchOp) => RoleRecord;
254
287
  readonly listDrones: (cubeId: string) => DroneRecord[];
288
+ readonly listDronesSince: (cubeId: string, since: string) => {
289
+ readonly drones: DroneRecord[];
290
+ readonly since: string;
291
+ };
292
+ readonly reassignDrone: (cubeId: string, droneId: string, roleId: string) => DroneRecord;
293
+ readonly evictDrone: (cubeId: string, droneId: string) => void;
255
294
  readonly appendLog: (cubeId: string, input: {
256
295
  readonly message: string;
257
296
  readonly visibility?: "broadcast" | "direct";
@@ -267,7 +306,7 @@ export interface ScopedStore {
267
306
  readonly listDecisions: (cubeId: string) => DecisionRecord[];
268
307
  readonly subscribeActivity: (
269
308
  cubeId: string,
270
- listener: (entry: EnrichedActivityRecord) => void,
309
+ listener: (entry: ActivityStreamRecord) => void,
271
310
  ) => (() => void);
272
311
  readonly attachSeat: (input: SeatAttachInput) => SeatAttachRecord;
273
312
  }
@@ -281,6 +320,7 @@ export interface CreateRoleInput {
281
320
  readonly isHumanSeat?: boolean;
282
321
  readonly canBroadcast?: boolean;
283
322
  readonly receivesAllDirect?: boolean;
323
+ readonly roleClass?: "queen" | "worker";
284
324
  }
285
325
 
286
326
  export interface UpdateRoleInput {
@@ -292,6 +332,12 @@ export interface UpdateRoleInput {
292
332
  readonly isHumanSeat?: boolean;
293
333
  readonly canBroadcast?: boolean;
294
334
  readonly receivesAllDirect?: boolean;
335
+ readonly roleClass?: "queen" | "worker";
336
+ }
337
+
338
+ export interface UpdateCubeInput {
339
+ readonly directive?: string;
340
+ readonly messageTaxonomy?: MessageTaxonomy | null;
295
341
  }
296
342
 
297
343
  export interface CreateCubeInput {
@@ -310,7 +356,6 @@ export interface CreateCubeRecord {
310
356
  export interface SeatAttachInput {
311
357
  readonly cubeId: string;
312
358
  readonly roleId: string;
313
- readonly retryKey: string;
314
359
  readonly priorDroneId?: string;
315
360
  readonly droneId: string;
316
361
  readonly sessionId: string;
@@ -336,10 +381,7 @@ export interface SeatAttachRecord {
336
381
  };
337
382
  readonly sessionId: string;
338
383
  readonly expiresAt: string;
339
- readonly generation: number;
340
- readonly posture: "observer" | "participant";
341
- readonly reattached: boolean;
342
- readonly revokedSessionIds: readonly string[];
384
+ readonly result: "created" | "reused";
343
385
  }
344
386
 
345
387
  export interface MaintenanceStore {
@@ -401,6 +443,24 @@ export interface MaintenanceStore {
401
443
  }) => void;
402
444
  readonly revokeClient: (clientId: string) => void;
403
445
  readonly revokeDroneSession: (sessionId: string) => void;
446
+ readonly expireDroneSession: (sessionId: string) => void;
447
+ readonly inspectManagedDrone: (droneId: string) => {
448
+ readonly role_id: string;
449
+ readonly evicted: boolean;
450
+ readonly session_revoked: boolean;
451
+ };
452
+ readonly inspectCubeManagementState: (cubeId: string) => {
453
+ readonly directive: string;
454
+ readonly taxonomy_marker: string | null;
455
+ readonly role_ids: readonly string[];
456
+ readonly active_decision_ids: readonly string[];
457
+ readonly drones: ReadonlyArray<{
458
+ readonly id: string;
459
+ readonly role_id: string;
460
+ readonly evicted: boolean;
461
+ readonly session_revoked: boolean;
462
+ }>;
463
+ };
404
464
  readonly expireActivityCursor: (cubeId: string, cursor: LogCursor) => void;
405
465
  }
406
466
 
@@ -440,6 +500,15 @@ export class RoleSectionConflictError extends Error {
440
500
  }
441
501
  }
442
502
 
503
+ export class RoleInUseError extends Error {
504
+ readonly code = "ROLE_IN_USE";
505
+
506
+ constructor() {
507
+ super("The human-seat role is already occupied.");
508
+ this.name = "RoleInUseError";
509
+ }
510
+ }
511
+
443
512
  export class CursorExpiredError extends Error {
444
513
  readonly code = "CURSOR_EXPIRED";
445
514
 
@@ -449,12 +518,12 @@ export class CursorExpiredError extends Error {
449
518
  }
450
519
  }
451
520
 
452
- export class AttachConflictError extends Error {
453
- readonly code = "ATTACH_CONFLICT";
521
+ export class AttachSessionRejectedError extends Error {
522
+ readonly code = "SESSION_REJECTED";
454
523
 
455
524
  constructor() {
456
- super("The attach request conflicts with an existing attachment.");
457
- this.name = "AttachConflictError";
525
+ super("The saved session is not accepted for this seat.");
526
+ this.name = "AttachSessionRejectedError";
458
527
  }
459
528
  }
460
529
 
@@ -482,6 +551,7 @@ interface CubeRow {
482
551
  readonly owner_id: string;
483
552
  readonly name: string;
484
553
  readonly directive: string;
554
+ readonly message_taxonomy: string | null;
485
555
  readonly created_at: string;
486
556
  readonly updated_at: string;
487
557
  }
@@ -573,9 +643,10 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
573
643
  capacityProbe,
574
644
  requiredInteger(pageRow, "page_size"),
575
645
  );
646
+ const activityHub = new ActivityHub();
576
647
  const maintenance = new SqliteMaintenanceStore(database, clock);
648
+ const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
577
649
  const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
578
- const activityHub = new ActivityHub();
579
650
  return Object.freeze({
580
651
  forPrincipal: (principal: Principal) => {
581
652
  assertServerDerivedPrincipal(principal);
@@ -592,6 +663,7 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
592
663
  },
593
664
  maintenance,
594
665
  credentials,
666
+ liveness,
595
667
  diagnostics: () => diagnostics(database),
596
668
  close: () => database.close(),
597
669
  });
@@ -712,7 +784,8 @@ class SqliteScopedStore implements ScopedStore {
712
784
  listCubes(): CubeRecord[] {
713
785
  const scope = this.#scope("read");
714
786
  const rows = this.#database.prepare(`
715
- SELECT c.id, c.owner_id, c.name, c.directive, c.created_at, c.updated_at
787
+ SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
788
+ c.created_at, c.updated_at
716
789
  FROM cubes AS c
717
790
  WHERE ${scope.sql}
718
791
  ORDER BY c.id
@@ -724,7 +797,8 @@ class SqliteScopedStore implements ScopedStore {
724
797
  assertCanonicalUuid(cubeId, "Cube id");
725
798
  const scope = this.#scope("read");
726
799
  const row = this.#database.prepare(`
727
- SELECT c.id, c.owner_id, c.name, c.directive, c.created_at, c.updated_at
800
+ SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
801
+ c.created_at, c.updated_at
728
802
  FROM cubes AS c
729
803
  WHERE c.id = ? AND ${scope.sql}
730
804
  `).get(cubeId, ...scope.parameters);
@@ -732,17 +806,42 @@ class SqliteScopedStore implements ScopedStore {
732
806
  }
733
807
 
734
808
  updateDirective(cubeId: string, directive: string): void {
809
+ this.updateCube(cubeId, { directive });
810
+ }
811
+
812
+ updateCube(cubeId: string, input: UpdateCubeInput): CubeRecord {
735
813
  assertCanonicalUuid(cubeId, "Cube id");
736
- validateDirective(directive);
814
+ if (input.directive === undefined && input.messageTaxonomy === undefined) {
815
+ throw new TypeError("At least one cube field is required.");
816
+ }
817
+ if (input.directive !== undefined) validateDirective(input.directive);
818
+ const taxonomy = input.messageTaxonomy === undefined
819
+ ? undefined
820
+ : validateMessageTaxonomy(input.messageTaxonomy);
821
+ const serializedTaxonomy = taxonomy === undefined ? undefined : serializeTaxonomy(taxonomy);
737
822
  const scope = this.#scope("manage");
738
823
  this.#requireCube(cubeId, "manage");
739
- this.#capacityGuard.assertCanGrow(Buffer.byteLength(directive));
824
+ this.#capacityGuard.assertCanGrow(
825
+ Buffer.byteLength(input.directive ?? "") + Buffer.byteLength(serializedTaxonomy ?? ""),
826
+ );
740
827
  const result = this.#database.prepare(`
741
828
  UPDATE cubes AS c
742
- SET directive = ?, updated_at = ?
829
+ SET directive = COALESCE(?, directive),
830
+ message_taxonomy = CASE WHEN ? = 1 THEN ? ELSE message_taxonomy END,
831
+ updated_at = ?
743
832
  WHERE c.id = ? AND ${scope.sql}
744
- `).run(directive, this.#now(), cubeId, ...scope.parameters);
833
+ `).run(
834
+ input.directive ?? null,
835
+ serializedTaxonomy === undefined ? 0 : 1,
836
+ serializedTaxonomy ?? null,
837
+ this.#now(),
838
+ cubeId,
839
+ ...scope.parameters,
840
+ );
745
841
  if (result.changes !== 1) throw new ScopedStoreError();
842
+ const cube = this.getCube(cubeId);
843
+ if (cube === null) throw new ScopedStoreError();
844
+ return cube;
746
845
  }
747
846
 
748
847
  appendActivity(cubeId: string, message: string): ActivityRecord {
@@ -803,6 +902,7 @@ class SqliteScopedStore implements ScopedStore {
803
902
  ]) {
804
903
  if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
805
904
  }
905
+ validateRoleClass(input.roleClass);
806
906
  const isDefault = input.isDefault ?? false;
807
907
  const isMandatory = input.isMandatory ?? false;
808
908
  const isHumanSeat = input.isHumanSeat ?? false;
@@ -832,11 +932,12 @@ class SqliteScopedStore implements ScopedStore {
832
932
  id, cube_id, name, short_description, detailed_description,
833
933
  is_default, is_mandatory, is_human_seat, can_broadcast,
834
934
  receives_all_direct, role_class, created_at
835
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'worker', ?)
935
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
836
936
  `).run(
837
937
  id, cubeId, input.name, shortDescription, detailedDescription,
838
938
  booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat),
839
- booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), this.#now(),
939
+ booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), input.roleClass ?? "worker",
940
+ this.#now(),
840
941
  );
841
942
  this.#mutationHook?.("role.insert");
842
943
  const row = this.#database.prepare(`
@@ -875,6 +976,7 @@ class SqliteScopedStore implements ScopedStore {
875
976
  ]) {
876
977
  if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
877
978
  }
979
+ validateRoleClass(input.roleClass);
878
980
  this.#requireCube(cubeId, "manage");
879
981
  this.#capacityGuard.assertCanGrow(
880
982
  Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
@@ -911,7 +1013,8 @@ class SqliteScopedStore implements ScopedStore {
911
1013
  this.#database.prepare(`
912
1014
  UPDATE roles SET
913
1015
  name = ?, short_description = ?, detailed_description = ?, is_default = ?,
914
- is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?
1016
+ is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?,
1017
+ role_class = ?
915
1018
  WHERE id = ? AND cube_id = ?
916
1019
  `).run(
917
1020
  input.name ?? existing.name,
@@ -922,6 +1025,7 @@ class SqliteScopedStore implements ScopedStore {
922
1025
  booleanInteger(input.isHumanSeat ?? existing.is_human_seat),
923
1026
  booleanInteger(input.canBroadcast ?? existing.can_broadcast),
924
1027
  booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct),
1028
+ input.roleClass ?? existing.role_class,
925
1029
  roleId,
926
1030
  cubeId,
927
1031
  );
@@ -1006,6 +1110,129 @@ class SqliteScopedStore implements ScopedStore {
1006
1110
  return rows.map(droneRecord);
1007
1111
  }
1008
1112
 
1113
+ listDronesSince(cubeId: string, since: string): { drones: DroneRecord[]; since: string } {
1114
+ this.#requireCube(cubeId, "read");
1115
+ let createdAt: string;
1116
+ let anchorId: string | null = null;
1117
+ if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/iu.test(since)) {
1118
+ const anchor = this.#database.prepare(
1119
+ "SELECT id, created_at FROM activity_log WHERE id = ? AND cube_id = ?",
1120
+ ).get(since, cubeId);
1121
+ if (anchor === undefined) throw new ScopedStoreError();
1122
+ anchorId = requiredText(anchor, "id");
1123
+ createdAt = requiredText(anchor, "created_at");
1124
+ } else {
1125
+ validateTimestamp(since);
1126
+ createdAt = since;
1127
+ }
1128
+ const rows = this.#database.prepare(`
1129
+ SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
1130
+ COALESCE(drone.last_seen, drone.created_at) AS last_seen,
1131
+ drone.hostname, drone.created_at,
1132
+ CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
1133
+ THEN 'participant' ELSE 'observer' END AS posture,
1134
+ (SELECT MAX(post.created_at) FROM activity_log AS post
1135
+ WHERE post.cube_id = drone.cube_id AND post.drone_id = drone.id) AS last_log_post_at,
1136
+ EXISTS (SELECT 1 FROM activity_log AS response
1137
+ WHERE response.cube_id = drone.cube_id AND response.drone_id = drone.id
1138
+ AND (response.created_at > ? OR
1139
+ (? IS NOT NULL AND response.created_at = ? AND response.id > ?))) AS seen_since
1140
+ FROM drones AS drone
1141
+ LEFT JOIN clients AS client ON client.id = drone.client_id
1142
+ LEFT JOIN client_cube_grants AS grant_row
1143
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1144
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
1145
+ ORDER BY drone.label, drone.id
1146
+ `).all(createdAt, anchorId, createdAt, anchorId, cubeId);
1147
+ return {
1148
+ since: createdAt,
1149
+ drones: rows.map((row) => ({
1150
+ ...droneRecord(row),
1151
+ last_log_post_at: nullableText(row, "last_log_post_at"),
1152
+ seen_since: requiredInteger(row, "seen_since") === 1,
1153
+ })),
1154
+ };
1155
+ }
1156
+
1157
+ reassignDrone(cubeId: string, droneId: string, roleId: string): DroneRecord {
1158
+ assertCanonicalUuid(cubeId, "Cube id");
1159
+ assertCanonicalUuid(droneId, "Drone id");
1160
+ assertCanonicalUuid(roleId, "Role id");
1161
+ this.#requireCube(cubeId, "manage");
1162
+ this.#database.exec("BEGIN IMMEDIATE");
1163
+ try {
1164
+ this.#requireCube(cubeId, "manage");
1165
+ const droneRow = this.#database.prepare(`
1166
+ SELECT id, cube_id, role_id, label FROM drones
1167
+ WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
1168
+ `).get(droneId, cubeId);
1169
+ const targetRoleRow = this.#database.prepare(`
1170
+ SELECT id, is_human_seat, role_class FROM roles WHERE id = ? AND cube_id = ?
1171
+ `).get(roleId, cubeId);
1172
+ if (droneRow === undefined || targetRoleRow === undefined) throw new ScopedStoreError();
1173
+ const sourceRoleRow = this.#database.prepare(`
1174
+ SELECT is_human_seat FROM roles WHERE id = ? AND cube_id = ?
1175
+ `).get(requiredText(droneRow, "role_id"), cubeId);
1176
+ if (sourceRoleRow === undefined) throw new ScopedStoreError();
1177
+ if (requiredText(targetRoleRow, "role_class") === "queen" &&
1178
+ requiredInteger(sourceRoleRow, "is_human_seat") !== 1) {
1179
+ throw new AccessDeniedError();
1180
+ }
1181
+ if (requiredInteger(targetRoleRow, "is_human_seat") === 1) {
1182
+ const occupied = this.#database.prepare(`
1183
+ SELECT 1 AS present FROM drones
1184
+ WHERE cube_id = ? AND role_id = ? AND id <> ? AND evicted_at IS NULL
1185
+ `).get(cubeId, roleId, droneId);
1186
+ if (occupied !== undefined) throw new RoleInUseError();
1187
+ }
1188
+ this.#database.prepare("UPDATE drones SET role_id = ? WHERE id = ? AND cube_id = ?")
1189
+ .run(roleId, droneId, cubeId);
1190
+ const row = this.#database.prepare(`
1191
+ SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
1192
+ COALESCE(drone.last_seen, drone.created_at) AS last_seen,
1193
+ drone.hostname, drone.created_at,
1194
+ CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
1195
+ THEN 'participant' ELSE 'observer' END AS posture
1196
+ FROM drones AS drone
1197
+ LEFT JOIN clients AS client ON client.id = drone.client_id
1198
+ LEFT JOIN client_cube_grants AS grant_row
1199
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1200
+ WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
1201
+ `).get(droneId, cubeId);
1202
+ if (row === undefined) throw new ScopedStoreError();
1203
+ this.#database.exec("COMMIT");
1204
+ return droneRecord(row);
1205
+ } catch (error) {
1206
+ try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
1207
+ throw error;
1208
+ }
1209
+ }
1210
+
1211
+ evictDrone(cubeId: string, droneId: string): void {
1212
+ assertCanonicalUuid(cubeId, "Cube id");
1213
+ assertCanonicalUuid(droneId, "Drone id");
1214
+ this.#requireCube(cubeId, "manage");
1215
+ this.#database.exec("BEGIN IMMEDIATE");
1216
+ try {
1217
+ this.#requireCube(cubeId, "manage");
1218
+ const active = this.#database.prepare(`
1219
+ SELECT 1 AS present FROM drones WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
1220
+ `).get(droneId, cubeId);
1221
+ if (active === undefined) throw new ScopedStoreError();
1222
+ const now = this.#now();
1223
+ this.#database.prepare("UPDATE drones SET evicted_at = ? WHERE id = ? AND cube_id = ?")
1224
+ .run(now, droneId, cubeId);
1225
+ this.#database.prepare(`
1226
+ UPDATE drone_sessions SET revoked_at = ?
1227
+ WHERE drone_id = ? AND cube_id = ? AND revoked_at IS NULL
1228
+ `).run(now, droneId, cubeId);
1229
+ this.#database.exec("COMMIT");
1230
+ } catch (error) {
1231
+ try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
1232
+ throw error;
1233
+ }
1234
+ }
1235
+
1009
1236
  appendLog(cubeId: string, input: {
1010
1237
  readonly message: string;
1011
1238
  readonly visibility?: "broadcast" | "direct";
@@ -1052,6 +1279,12 @@ class SqliteScopedStore implements ScopedStore {
1052
1279
  createdAt, visibility, cubeId, ...scope.parameters,
1053
1280
  );
1054
1281
  if (inserted.changes !== 1) throw new ScopedStoreError();
1282
+ if (droneId !== null) {
1283
+ this.#database.prepare(`
1284
+ UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
1285
+ `).run(createdAt, droneId, cubeId);
1286
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1287
+ }
1055
1288
  if (recipients.length > 0) {
1056
1289
  const valid = this.#database.prepare(`
1057
1290
  SELECT COUNT(*) AS count
@@ -1125,19 +1358,56 @@ class SqliteScopedStore implements ScopedStore {
1125
1358
  this.#requireCube(cubeId, "write");
1126
1359
  assertCanonicalUuid(entryId, "Activity entry id");
1127
1360
  if (kind !== "ack" && kind !== "claim") throw new Error("Unknown acknowledgement kind.");
1128
- const exists = this.#database.prepare(
1129
- "SELECT 1 AS present FROM activity_log WHERE id = ? AND cube_id = ?",
1130
- ).get(entryId, cubeId);
1131
- if (exists === undefined) throw new ScopedStoreError();
1361
+ const original = this.#database.prepare(`
1362
+ SELECT drone_id, message, visibility FROM activity_log WHERE id = ? AND cube_id = ?
1363
+ `).get(entryId, cubeId);
1364
+ if (original === undefined) throw new ScopedStoreError();
1132
1365
  this.#capacityGuard.assertCanGrow(512);
1133
1366
  const claimant = this.#principal.kind === "drone-session"
1134
1367
  ? this.#principal.droneId
1135
1368
  : this.#principal.id;
1136
- this.#database.prepare(`
1369
+ const ackAt = this.#now();
1370
+ const inserted = this.#database.prepare(`
1137
1371
  INSERT OR IGNORE INTO activity_acks (
1138
1372
  entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
1139
1373
  ) VALUES (?, ?, ?, ?, ?, ?)
1140
- `).run(entryId, this.#principal.kind, this.#principal.id, kind, this.#now(), claimant);
1374
+ `).run(entryId, this.#principal.kind, this.#principal.id, kind, ackAt, claimant);
1375
+ if (inserted.changes !== 1 || this.#principal.kind !== "drone-session") return;
1376
+ const claimantDroneId = this.#principal.droneId;
1377
+
1378
+ const actor = this.#database.prepare(`
1379
+ SELECT drone.label, role.name AS role_name
1380
+ FROM drones AS drone JOIN roles AS role ON role.id = drone.role_id
1381
+ WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
1382
+ `).get(claimantDroneId, cubeId);
1383
+ const preview = activityPreview(requiredText(original, "message"));
1384
+ const originalAuthor = nullableText(original, "drone_id");
1385
+ const recipients = kind === "ack"
1386
+ ? originalAuthor === null ? [] : [originalAuthor]
1387
+ : this.#claimAudience(cubeId, entryId, requiredText(original, "visibility"))
1388
+ .filter((droneId) => droneId !== claimantDroneId);
1389
+ if (recipients.length === 0) return;
1390
+ const roleName = actor === undefined ? null : requiredText(actor, "role_name");
1391
+ const notification: ActivityNotificationRecord = {
1392
+ kind,
1393
+ id: randomUUID(),
1394
+ cube_id: cubeId,
1395
+ drone_id: claimantDroneId,
1396
+ drone_label: actor === undefined ? null : requiredText(actor, "label"),
1397
+ role_name: roleName,
1398
+ message: `[${kind.toUpperCase()}] ${preview}`,
1399
+ created_at: ackAt,
1400
+ visibility: "direct",
1401
+ recipient_drone_ids: recipients,
1402
+ log_entry_id: entryId,
1403
+ ack_kind: kind,
1404
+ ack_at: ackAt,
1405
+ entry_preview: preview,
1406
+ ...(kind === "ack"
1407
+ ? { author_drone_id: recipients[0]! }
1408
+ : { claimant_drone_id: claimantDroneId, claimant_role: roleName }),
1409
+ };
1410
+ this.#activityHub.publish(cubeId, notification);
1141
1411
  }
1142
1412
 
1143
1413
  recordDecision(cubeId: string, input: {
@@ -1194,7 +1464,6 @@ class SqliteScopedStore implements ScopedStore {
1194
1464
  if (this.#principal.kind !== "client") throw new ScopedStoreError();
1195
1465
  assertCanonicalUuid(input.cubeId, "Cube id");
1196
1466
  assertCanonicalUuid(input.roleId, "Role id");
1197
- assertCanonicalUuid(input.retryKey, "Retry key");
1198
1467
  if (input.priorDroneId !== undefined) assertCanonicalUuid(input.priorDroneId, "Prior drone id");
1199
1468
  assertCanonicalUuid(input.droneId, "Drone id");
1200
1469
  assertCanonicalUuid(input.sessionId, "Drone session id");
@@ -1219,95 +1488,96 @@ class SqliteScopedStore implements ScopedStore {
1219
1488
  WHERE c.id = ? AND role.id = ? AND ${scope.sql}
1220
1489
  `).get(input.cubeId, input.roleId, ...scope.parameters);
1221
1490
  if (roleRow === undefined) throw new ScopedStoreError();
1222
- const posture = this.#allowsDirectedWork(input.cubeId) ? "participant" : "observer";
1223
-
1224
- const retryBinding = this.#database.prepare(`
1225
- SELECT binding.cube_id AS binding_cube_id,
1226
- binding.requested_role_id AS binding_requested_role_id,
1227
- binding.prior_drone_id, drone.id, drone.label,
1228
- drone.attach_generation, drone.evicted_at
1229
- FROM seat_attach_bindings AS binding
1230
- JOIN drones AS drone ON drone.id = binding.drone_id
1231
- WHERE binding.client_id = ? AND binding.retry_key = ?
1232
- `).get(this.#principal.id, input.retryKey);
1491
+ const bound = this.#database.prepare(`
1492
+ SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
1493
+ session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
1494
+ session.expires_at, session.revoked_at AS session_revoked_at,
1495
+ drone.role_id, drone.label, drone.evicted_at
1496
+ FROM drone_session_credentials AS credential
1497
+ JOIN drone_sessions AS session ON session.id = credential.session_id
1498
+ JOIN drones AS drone ON drone.id = session.drone_id
1499
+ AND drone.client_id = session.client_id AND drone.cube_id = session.cube_id
1500
+ WHERE credential.lookup_digest = ?
1501
+ `).get(input.credentialDigest.lookup);
1502
+ if (bound !== undefined) {
1503
+ const verifier = requiredBuffer(bound, "verifier_digest");
1504
+ if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
1505
+ optionalText(bound, "credential_revoked_at") !== null ||
1506
+ optionalText(bound, "session_revoked_at") !== null ||
1507
+ optionalText(bound, "evicted_at") !== null ||
1508
+ requiredText(bound, "expires_at") <= now ||
1509
+ requiredText(bound, "client_id") !== this.#principal.id ||
1510
+ requiredText(bound, "cube_id") !== input.cubeId ||
1511
+ requiredText(bound, "role_id") !== input.roleId ||
1512
+ (input.priorDroneId !== undefined &&
1513
+ requiredText(bound, "drone_id") !== input.priorDroneId)) {
1514
+ throw new AttachSessionRejectedError();
1515
+ }
1516
+ const droneId = requiredText(bound, "drone_id");
1517
+ this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
1518
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1519
+ const attachedRoleRow = this.#database.prepare(`
1520
+ SELECT id AS role_id, name AS role_name, role_class, is_human_seat
1521
+ FROM roles WHERE id = ? AND cube_id = ?
1522
+ `).get(requiredText(bound, "role_id"), input.cubeId);
1523
+ if (attachedRoleRow === undefined) throw new Error("Attached drone role is unavailable.");
1524
+ const roleClass = requiredText(attachedRoleRow, "role_class");
1525
+ if (roleClass !== "queen" && roleClass !== "worker") {
1526
+ throw new Error("Database contains invalid role class.");
1527
+ }
1528
+ this.#database.exec("COMMIT");
1529
+ return {
1530
+ cube: {
1531
+ id: requiredText(roleRow, "cube_id"),
1532
+ name: requiredText(roleRow, "cube_name"),
1533
+ },
1534
+ role: {
1535
+ id: requiredText(attachedRoleRow, "role_id"),
1536
+ name: requiredText(attachedRoleRow, "role_name"),
1537
+ role_class: roleClass,
1538
+ is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
1539
+ },
1540
+ drone: { id: droneId, label: requiredText(bound, "label") },
1541
+ sessionId: requiredText(bound, "session_id"),
1542
+ expiresAt: requiredText(bound, "expires_at"),
1543
+ result: "reused",
1544
+ };
1545
+ }
1546
+
1547
+ const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
1548
+ SELECT id, role_id, label FROM drones
1549
+ WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
1550
+ `).get(input.priorDroneId, this.#principal.id, input.cubeId);
1233
1551
  let droneId: string;
1234
1552
  let droneLabel: string;
1235
- let reattached: boolean;
1236
- let generation: number;
1237
- if (retryBinding !== undefined) {
1238
- if (optionalText(retryBinding, "evicted_at") != null) throw new ScopedStoreError();
1239
- if (requiredText(retryBinding, "binding_cube_id") !== input.cubeId ||
1240
- requiredText(retryBinding, "binding_requested_role_id") !== input.roleId ||
1241
- nullableText(retryBinding, "prior_drone_id") !== (input.priorDroneId ?? null)) {
1242
- throw new AttachConflictError();
1553
+ if (priorSeat !== undefined) {
1554
+ const priorSeatId = requiredText(priorSeat, "id");
1555
+ if (requiredText(priorSeat, "role_id") !== input.roleId) {
1556
+ throw new AttachSessionRejectedError();
1243
1557
  }
1244
- droneId = requiredText(retryBinding, "id");
1245
- droneLabel = requiredText(retryBinding, "label");
1246
- generation = requiredInteger(retryBinding, "attach_generation") + 1;
1247
- this.#database.prepare(`
1248
- UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
1249
- `).run(now, generation, droneId);
1250
- reattached = true;
1558
+ const occupied = this.#database.prepare(`
1559
+ SELECT 1 FROM drone_sessions AS session
1560
+ JOIN drone_session_credentials AS credential ON credential.session_id = session.id
1561
+ WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
1562
+ AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
1563
+ AND session.expires_at > ?
1564
+ `).get(priorSeatId, this.#principal.id, input.cubeId, now);
1565
+ if (occupied !== undefined) throw new AttachSessionRejectedError();
1566
+ droneId = priorSeatId;
1567
+ droneLabel = requiredText(priorSeat, "label");
1568
+ this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
1251
1569
  } else {
1252
- const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
1253
- SELECT id, label, attach_generation
1254
- FROM drones
1255
- WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
1256
- `).get(input.priorDroneId, this.#principal.id, input.cubeId);
1257
- if (priorSeat !== undefined) {
1258
- droneId = requiredText(priorSeat, "id");
1259
- droneLabel = requiredText(priorSeat, "label");
1260
- generation = requiredInteger(priorSeat, "attach_generation") + 1;
1261
- this.#database.prepare(`
1262
- UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
1263
- `).run(now, generation, droneId);
1264
- reattached = true;
1265
- } else {
1266
- droneId = input.droneId;
1267
- droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
1268
- this.#database.prepare(`
1269
- INSERT INTO drones (
1270
- id, cube_id, role_id, client_id, label, created_at, last_seen, retry_key,
1271
- attach_generation
1272
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
1273
- `).run(
1274
- droneId,
1275
- input.cubeId,
1276
- input.roleId,
1277
- this.#principal.id,
1278
- droneLabel,
1279
- now,
1280
- now,
1281
- input.retryKey,
1282
- );
1283
- reattached = false;
1284
- generation = 1;
1285
- }
1570
+ droneId = input.droneId;
1571
+ droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
1286
1572
  this.#database.prepare(`
1287
- INSERT INTO seat_attach_bindings (
1288
- client_id, retry_key, cube_id, requested_role_id, drone_id, prior_drone_id, created_at
1289
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
1573
+ INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at, last_seen)
1574
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1290
1575
  `).run(
1291
- this.#principal.id, input.retryKey, input.cubeId, input.roleId,
1292
- droneId, input.priorDroneId ?? null, now,
1576
+ droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now,
1293
1577
  );
1294
1578
  }
1579
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1295
1580
 
1296
- const oldSessions = this.#database.prepare(
1297
- "SELECT id FROM drone_sessions WHERE drone_id = ? AND client_id = ? AND cube_id = ?",
1298
- ).all(droneId, this.#principal.id, input.cubeId)
1299
- .map((row) => requiredText(row, "id"));
1300
- if (oldSessions.length > 0) {
1301
- const placeholders = oldSessions.map(() => "?").join(", ");
1302
- this.#database.prepare(`
1303
- UPDATE drone_session_credentials SET revoked_at = ?
1304
- WHERE session_id IN (${placeholders}) AND revoked_at IS NULL
1305
- `).run(now, ...oldSessions);
1306
- this.#database.prepare(`
1307
- UPDATE drone_sessions SET revoked_at = ?
1308
- WHERE id IN (${placeholders}) AND revoked_at IS NULL
1309
- `).run(now, ...oldSessions);
1310
- }
1311
1581
  this.#database.prepare(`
1312
1582
  INSERT INTO drone_sessions (
1313
1583
  id, client_id, cube_id, drone_id, created_at, expires_at
@@ -1355,10 +1625,7 @@ class SqliteScopedStore implements ScopedStore {
1355
1625
  drone: { id: droneId, label: droneLabel },
1356
1626
  sessionId: input.sessionId,
1357
1627
  expiresAt: input.expiresAt,
1358
- generation,
1359
- posture,
1360
- reattached,
1361
- revokedSessionIds: oldSessions,
1628
+ result: "created",
1362
1629
  };
1363
1630
  } catch (error) {
1364
1631
  try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
@@ -1366,13 +1633,44 @@ class SqliteScopedStore implements ScopedStore {
1366
1633
  }
1367
1634
  }
1368
1635
 
1369
- subscribeActivity(cubeId: string, listener: (entry: EnrichedActivityRecord) => void): () => void {
1636
+ subscribeActivity(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
1370
1637
  this.#requireCube(cubeId, "read");
1371
1638
  return this.#activityHub.subscribe(cubeId, (entry) => {
1639
+ if ("kind" in entry) {
1640
+ if (this.#principal.kind === "drone-session" &&
1641
+ entry.recipient_drone_ids.includes(this.#principal.droneId)) listener(entry);
1642
+ return;
1643
+ }
1372
1644
  if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId)) listener(entry);
1373
1645
  });
1374
1646
  }
1375
1647
 
1648
+ #claimAudience(cubeId: string, entryId: string, visibility: string): string[] {
1649
+ if (visibility === "direct") {
1650
+ return this.#database.prepare(`
1651
+ SELECT recipient.drone_id
1652
+ FROM activity_log_recipients AS recipient
1653
+ JOIN drones AS drone ON drone.id = recipient.drone_id
1654
+ JOIN clients AS client ON client.id = drone.client_id
1655
+ JOIN client_cube_grants AS grant_row
1656
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1657
+ WHERE recipient.entry_id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
1658
+ AND client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
1659
+ ORDER BY recipient.drone_id
1660
+ `).all(entryId, cubeId).map((row) => requiredText(row, "drone_id"));
1661
+ }
1662
+ return this.#database.prepare(`
1663
+ SELECT drone.id AS drone_id
1664
+ FROM drones AS drone
1665
+ JOIN clients AS client ON client.id = drone.client_id
1666
+ JOIN client_cube_grants AS grant_row
1667
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1668
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL AND client.revoked_at IS NULL
1669
+ AND grant_row.access IN ('write', 'manage')
1670
+ ORDER BY drone.id
1671
+ `).all(cubeId).map((row) => requiredText(row, "drone_id"));
1672
+ }
1673
+
1376
1674
  #allowsDirectedWork(cubeId: string): boolean {
1377
1675
  const scope = this.#scope("write");
1378
1676
  return this.#database.prepare(`
@@ -1386,7 +1684,15 @@ class SqliteScopedStore implements ScopedStore {
1386
1684
  const row = this.#database.prepare(`
1387
1685
  SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
1388
1686
  `).get(cubeId, ...scope.parameters);
1389
- if (row === undefined) throw new ScopedStoreError();
1687
+ if (row !== undefined) return;
1688
+ if (access === "manage") {
1689
+ const visibleScope = this.#scope("read");
1690
+ const visible = this.#database.prepare(`
1691
+ SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${visibleScope.sql}
1692
+ `).get(cubeId, ...visibleScope.parameters);
1693
+ if (visible !== undefined) throw new AccessDeniedError();
1694
+ }
1695
+ throw new ScopedStoreError();
1390
1696
  }
1391
1697
 
1392
1698
  #nextActivityTimestamp(cubeId: string): string {
@@ -1644,7 +1950,7 @@ class SqliteMaintenanceStore implements MaintenanceStore {
1644
1950
  for (const table of [
1645
1951
  "cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
1646
1952
  "decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
1647
- "seat_attach_bindings", "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
1953
+ "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
1648
1954
  "enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
1649
1955
  "enrollment_invitations",
1650
1956
  ]) this.#database.exec(`DELETE FROM ${table}`);
@@ -1792,6 +2098,59 @@ class SqliteMaintenanceStore implements MaintenanceStore {
1792
2098
  ).run(this.#now(), sessionId);
1793
2099
  }
1794
2100
 
2101
+ expireDroneSession(sessionId: string): void {
2102
+ assertCanonicalUuid(sessionId, "Drone session id");
2103
+ this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
2104
+ .run(new Date(0).toISOString(), sessionId);
2105
+ }
2106
+
2107
+ inspectManagedDrone(droneId: string) {
2108
+ assertCanonicalUuid(droneId, "Drone id");
2109
+ const row = this.#database.prepare(`
2110
+ SELECT drone.role_id, drone.evicted_at,
2111
+ EXISTS(SELECT 1 FROM drone_sessions AS session
2112
+ WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
2113
+ FROM drones AS drone WHERE drone.id = ?
2114
+ `).get(droneId);
2115
+ if (row === undefined) throw new ScopedStoreError();
2116
+ return {
2117
+ role_id: requiredText(row, "role_id"),
2118
+ evicted: nullableText(row, "evicted_at") !== null,
2119
+ session_revoked: requiredInteger(row, "session_revoked") === 1,
2120
+ };
2121
+ }
2122
+
2123
+ inspectCubeManagementState(cubeId: string) {
2124
+ assertCanonicalUuid(cubeId, "Cube id");
2125
+ const cube = this.#database.prepare(
2126
+ "SELECT directive, message_taxonomy FROM cubes WHERE id = ?",
2127
+ ).get(cubeId);
2128
+ if (cube === undefined) throw new ScopedStoreError();
2129
+ const taxonomy = nullableText(cube, "message_taxonomy");
2130
+ const parsed = taxonomy === null ? null : JSON.parse(taxonomy) as Array<{ class?: unknown }>;
2131
+ return {
2132
+ directive: requiredText(cube, "directive"),
2133
+ taxonomy_marker: typeof parsed?.[0]?.class === "string" ? parsed[0].class : null,
2134
+ role_ids: this.#database.prepare(
2135
+ "SELECT id FROM roles WHERE cube_id = ? ORDER BY id",
2136
+ ).all(cubeId).map((row) => requiredText(row, "id")),
2137
+ active_decision_ids: this.#database.prepare(`
2138
+ SELECT id FROM decisions WHERE cube_id = ? AND status = 'active' ORDER BY id
2139
+ `).all(cubeId).map((row) => requiredText(row, "id")),
2140
+ drones: this.#database.prepare(`
2141
+ SELECT drone.id, drone.role_id, drone.evicted_at,
2142
+ EXISTS(SELECT 1 FROM drone_sessions AS session
2143
+ WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
2144
+ FROM drones AS drone WHERE drone.cube_id = ? ORDER BY drone.id
2145
+ `).all(cubeId).map((row) => ({
2146
+ id: requiredText(row, "id"),
2147
+ role_id: requiredText(row, "role_id"),
2148
+ evicted: nullableText(row, "evicted_at") !== null,
2149
+ session_revoked: requiredInteger(row, "session_revoked") === 1,
2150
+ })),
2151
+ };
2152
+ }
2153
+
1795
2154
  expireActivityCursor(cubeId: string, cursor: LogCursor): void {
1796
2155
  assertCanonicalUuid(cubeId, "Cube id");
1797
2156
  assertCanonicalUuid(cursor.id, "Activity cursor id");
@@ -1812,9 +2171,9 @@ class SqliteMaintenanceStore implements MaintenanceStore {
1812
2171
  }
1813
2172
 
1814
2173
  class ActivityHub {
1815
- readonly #listeners = new Map<string, Set<(entry: EnrichedActivityRecord) => void>>();
2174
+ readonly #listeners = new Map<string, Set<(entry: ActivityStreamRecord) => void>>();
1816
2175
 
1817
- subscribe(cubeId: string, listener: (entry: EnrichedActivityRecord) => void): () => void {
2176
+ subscribe(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
1818
2177
  const listeners = this.#listeners.get(cubeId) ?? new Set();
1819
2178
  listeners.add(listener);
1820
2179
  this.#listeners.set(cubeId, listeners);
@@ -1824,7 +2183,7 @@ class ActivityHub {
1824
2183
  };
1825
2184
  }
1826
2185
 
1827
- publish(cubeId: string, entry: EnrichedActivityRecord): void {
2186
+ publish(cubeId: string, entry: ActivityStreamRecord): void {
1828
2187
  for (const listener of this.#listeners.get(cubeId) ?? []) {
1829
2188
  try {
1830
2189
  listener(entry);
@@ -1835,6 +2194,64 @@ class ActivityHub {
1835
2194
  }
1836
2195
  }
1837
2196
 
2197
+ class SqliteLivenessStore implements LivenessStore {
2198
+ constructor(
2199
+ readonly database: DatabaseSync,
2200
+ readonly clock: () => Date,
2201
+ readonly hub: ActivityHub,
2202
+ readonly capacityGuard: StorageCapacityGuard,
2203
+ ) {}
2204
+
2205
+ scan(options: { silentMs?: number; cooldownMs?: number; limit?: number } = {}): ActivityNotificationRecord[] {
2206
+ const now = this.clock();
2207
+ const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
2208
+ const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
2209
+ const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
2210
+ const candidates = this.database.prepare(`
2211
+ SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
2212
+ FROM drones AS drone
2213
+ JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
2214
+ JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
2215
+ AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
2216
+ LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
2217
+ WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
2218
+ AND COALESCE(state.attempts, 0) < 3
2219
+ AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
2220
+ AND EXISTS (SELECT 1 FROM drone_sessions AS session
2221
+ WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
2222
+ ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
2223
+ `).all(silentBefore, cooldownBefore, now.toISOString(), limit);
2224
+ const published: ActivityNotificationRecord[] = [];
2225
+ for (const candidate of candidates) {
2226
+ const droneId = requiredText(candidate, "drone_id");
2227
+ const cubeId = requiredText(candidate, "cube_id");
2228
+ const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
2229
+ this.capacityGuard.assertCanGrow(256);
2230
+ const id = randomUUID();
2231
+ const createdAt = now.toISOString();
2232
+ this.database.exec("BEGIN IMMEDIATE");
2233
+ try {
2234
+ this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
2235
+ VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
2236
+ SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
2237
+ `).run(droneId, now.toISOString());
2238
+ this.database.exec("COMMIT");
2239
+ } catch (error) {
2240
+ try { this.database.exec("ROLLBACK"); } catch { /* Preserve original failure. */ }
2241
+ throw error;
2242
+ }
2243
+ const entry: ActivityNotificationRecord = {
2244
+ kind: "heartbeat_ping",
2245
+ id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
2246
+ drone_label: null, role_name: null, recipient_drone_ids: [droneId],
2247
+ };
2248
+ this.hub.publish(cubeId, entry);
2249
+ published.push(entry);
2250
+ }
2251
+ return published;
2252
+ }
2253
+ }
2254
+
1838
2255
  class SqliteCredentialStore implements CredentialStore {
1839
2256
  readonly #database: DatabaseSync;
1840
2257
  readonly #clock: () => Date;
@@ -2159,9 +2576,8 @@ class SqliteCredentialStore implements CredentialStore {
2159
2576
  SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
2160
2577
  credential.session_id, session.client_id, session.cube_id, session.drone_id,
2161
2578
  session.expires_at,
2162
- COALESCE(
2163
- credential.revoked_at, session.revoked_at, client.revoked_at, drone.evicted_at
2164
- ) AS revoked_at
2579
+ COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
2580
+ drone.evicted_at
2165
2581
  FROM drone_session_credentials AS credential
2166
2582
  JOIN drone_sessions AS session ON session.id = credential.session_id
2167
2583
  JOIN clients AS client ON client.id = session.client_id
@@ -2173,6 +2589,21 @@ class SqliteCredentialStore implements CredentialStore {
2173
2589
  return row === undefined ? null : storedDroneSessionDigest(row);
2174
2590
  }
2175
2591
 
2592
+ findActiveDroneSessionExpiry(sessionId: string): string | null {
2593
+ assertCanonicalUuid(sessionId, "Drone session id");
2594
+ const row = this.#database.prepare(`
2595
+ SELECT session.expires_at
2596
+ FROM drone_sessions AS session
2597
+ JOIN clients AS client ON client.id = session.client_id
2598
+ JOIN drones AS drone ON drone.id = session.drone_id
2599
+ AND drone.client_id = session.client_id
2600
+ AND drone.cube_id = session.cube_id
2601
+ WHERE session.id = ? AND session.revoked_at IS NULL
2602
+ AND client.revoked_at IS NULL AND drone.evicted_at IS NULL
2603
+ `).get(sessionId);
2604
+ return row === undefined ? null : requiredText(row, "expires_at");
2605
+ }
2606
+
2176
2607
  rotateClientCredential(input: {
2177
2608
  readonly clientId: string;
2178
2609
  readonly credentialId: string;
@@ -2195,6 +2626,16 @@ class SqliteCredentialStore implements CredentialStore {
2195
2626
  UPDATE client_credentials SET revoked_at = ?
2196
2627
  WHERE client_id = ? AND revoked_at IS NULL
2197
2628
  `).run(now, input.clientId);
2629
+ this.#database.prepare(`
2630
+ UPDATE drone_session_credentials SET revoked_at = ?
2631
+ WHERE revoked_at IS NULL AND session_id IN (
2632
+ SELECT id FROM drone_sessions WHERE client_id = ?
2633
+ )
2634
+ `).run(now, input.clientId);
2635
+ this.#database.prepare(`
2636
+ UPDATE drone_sessions SET revoked_at = ?
2637
+ WHERE client_id = ? AND revoked_at IS NULL
2638
+ `).run(now, input.clientId);
2198
2639
  this.#database.prepare(`
2199
2640
  INSERT INTO client_credentials (
2200
2641
  id, client_id, lookup_digest, verifier_digest, created_at
@@ -2361,6 +2802,7 @@ function cubeRecord(row: CubeRow): CubeRecord {
2361
2802
  ownerId: row.owner_id,
2362
2803
  name: row.name,
2363
2804
  directive: row.directive,
2805
+ messageTaxonomy: parseTaxonomy(row.message_taxonomy),
2364
2806
  createdAt: row.created_at,
2365
2807
  updatedAt: row.updated_at,
2366
2808
  };
@@ -2384,6 +2826,7 @@ function cubeRow(row: Record<string, unknown>): CubeRow {
2384
2826
  owner_id: requiredText(row, "owner_id"),
2385
2827
  name: requiredText(row, "name"),
2386
2828
  directive: requiredText(row, "directive"),
2829
+ message_taxonomy: nullableText(row, "message_taxonomy"),
2387
2830
  created_at: requiredText(row, "created_at"),
2388
2831
  updated_at: requiredText(row, "updated_at"),
2389
2832
  };
@@ -2611,6 +3054,7 @@ function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSess
2611
3054
  cubeId: requiredText(row, "cube_id"),
2612
3055
  droneId: requiredText(row, "drone_id"),
2613
3056
  expiresAt: requiredText(row, "expires_at"),
3057
+ evictedAt: nullableText(row, "evicted_at"),
2614
3058
  };
2615
3059
  }
2616
3060
 
@@ -2696,6 +3140,34 @@ function validateDirective(value: string): void {
2696
3140
  }
2697
3141
  }
2698
3142
 
3143
+ function activityPreview(message: string): string {
3144
+ return message.replace(/\s+/gu, " ").trim().slice(0, 200);
3145
+ }
3146
+
3147
+ function validateRoleClass(value: unknown): void {
3148
+ if (value !== undefined && value !== "queen" && value !== "worker") {
3149
+ throw new TypeError("Role class must be queen or worker.");
3150
+ }
3151
+ }
3152
+
3153
+ function serializeTaxonomy(value: MessageTaxonomy | null): string | null {
3154
+ if (value === null) return null;
3155
+ const serialized = JSON.stringify(value);
3156
+ if (Buffer.byteLength(serialized) > 100_000) {
3157
+ throw new RangeError("Message taxonomy exceeds 100000 bytes.");
3158
+ }
3159
+ return serialized;
3160
+ }
3161
+
3162
+ function parseTaxonomy(value: string | null): MessageTaxonomy | null {
3163
+ if (value === null) return null;
3164
+ try {
3165
+ return validateMessageTaxonomy(JSON.parse(value));
3166
+ } catch {
3167
+ throw new Error("Database contains invalid message taxonomy.");
3168
+ }
3169
+ }
3170
+
2699
3171
  function validateDigest(digest: DigestPair): void {
2700
3172
  validateLookup(digest.lookup);
2701
3173
  if (digest.verifier.length !== 32) throw new Error("Verifier digest must contain 32 bytes.");