borgmcp-shared 0.7.0 → 0.7.1

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.
@@ -4,6 +4,7 @@ import {
4
4
  createProtocolEnvelope,
5
5
  decodeAssociateRepositoryCubeResponseEnvelope,
6
6
  decodeCreateCubeResponseEnvelope,
7
+ decodeDeleteCubeResponseEnvelope,
7
8
  decodeAppendLogResultEnvelope,
8
9
  decodeDecisionResultEnvelope,
9
10
  decodeDecisionsResultEnvelope,
@@ -130,6 +131,20 @@ export interface ConformanceCreatedCubeState {
130
131
  default_worker_role_matches: boolean;
131
132
  }
132
133
 
134
+ export interface ConformanceDeletedCubeState {
135
+ readonly cube_exists: boolean;
136
+ readonly role_count: number;
137
+ readonly drone_count: number;
138
+ readonly log_count: number;
139
+ readonly claim_count: number;
140
+ readonly decision_count: number;
141
+ readonly grant_count: number;
142
+ readonly cube_create_binding_count: number;
143
+ readonly repository_association_count: number;
144
+ readonly active_stream_count: number;
145
+ readonly terminal_credential_count: number;
146
+ }
147
+
133
148
  export interface ConformanceRepositoryCubeFixture {
134
149
  cube_id: string;
135
150
  name: string;
@@ -168,6 +183,8 @@ export interface ConformanceStreamResponse extends ConformanceHttpResponse {
168
183
  */
169
184
  export interface ConformanceAdmin {
170
185
  reset(): Promise<void>;
186
+ /** Restarts the authority while preserving durable state. */
187
+ restartAuthority(): Promise<void>;
171
188
  createPrincipal(name: string): Promise<ConformancePrincipal>;
172
189
  createCube(name: string): Promise<ConformanceCube>;
173
190
  /** Grants the requested cube authority; omitted access defaults to manage. */
@@ -205,6 +222,7 @@ export interface ConformanceAdmin {
205
222
  creator: ConformancePrincipal,
206
223
  response: CreateCubeResponse,
207
224
  ): Promise<ConformanceCreatedCubeState>;
225
+ inspectDeletedCube(cube: ConformanceCube): Promise<ConformanceDeletedCubeState>;
208
226
  prepareRepositoryCube(
209
227
  cube: ConformanceCube,
210
228
  input: {
@@ -227,6 +245,11 @@ export interface ConformanceOperations {
227
245
  protocol(credential: string | null): Promise<ConformanceHttpResponse>;
228
246
  enroll(request: unknown): Promise<ConformanceHttpResponse>;
229
247
  createCube(credential: string | null, request: unknown): Promise<ConformanceHttpResponse>;
248
+ deleteCube(
249
+ credential: string,
250
+ cube: ConformanceCube,
251
+ request: unknown,
252
+ ): Promise<ConformanceHttpResponse>;
230
253
  resolveRepositoryCube(credential: string | null, request: unknown): Promise<ConformanceHttpResponse>;
231
254
  associateRepositoryCube(credential: string | null, request: unknown): Promise<ConformanceHttpResponse>;
232
255
  attach(credential: string, request: unknown): Promise<ConformanceHttpResponse>;
@@ -337,6 +360,7 @@ export const ADAPTER_CONFORMANCE_FIXTURES = [
337
360
  { id: 'security.metadata-noninterference', area: 'security' },
338
361
  { id: 'security.metadata-secret-non-echo', area: 'security' },
339
362
  { id: 'security.active-stream-revocation', area: 'security' },
363
+ { id: 'cubes.delete-terminal-cascade', area: 'cubes' },
340
364
  ] as const;
341
365
 
342
366
  export type AdapterConformanceFixtureId =
@@ -2431,6 +2455,276 @@ export async function runAdapterConformance(
2431
2455
  return { stream_terminated: true, subsequent_status: 401, subsequent_code: ErrorCode.SESSION_REVOKED };
2432
2456
  });
2433
2457
 
2458
+ await record('cubes.delete-terminal-cascade', async () => {
2459
+ const enrollParent = async (
2460
+ name: string,
2461
+ marker: string,
2462
+ retrySuffix: string,
2463
+ ): Promise<{ principal: ConformancePrincipal; credential: string }> => {
2464
+ const principal = await environment.admin.createPrincipal(name);
2465
+ const invitation = await environment.admin.issueSingleUseInvitation(principal, 'client');
2466
+ const credential = `${marker.repeat(41)}AQ`;
2467
+ expectStatus(await environment.operations.enroll(createProtocolEnvelope(
2468
+ `delete-${name}-enroll`,
2469
+ {
2470
+ invitation,
2471
+ retry_key: `00000000-0000-4000-8000-${retrySuffix.padStart(12, '0')}`,
2472
+ client_credential: credential,
2473
+ client_name: name,
2474
+ },
2475
+ )), 201, `${name} enrollment`);
2476
+ return { principal, credential };
2477
+ };
2478
+ const creator = await enrollParent('delete-creator', 'C', '701');
2479
+ const manager = await enrollParent('delete-manager', 'M', '702');
2480
+ const reader = await enrollParent('delete-reader', 'R', '703');
2481
+ const writer = await enrollParent('delete-writer', 'W', '704');
2482
+ const outsider = await enrollParent('delete-outsider', 'O', '705');
2483
+ await environment.admin.grantCreateCubeCapability(creator.principal);
2484
+ const createResponse = await environment.operations.createCube(
2485
+ creator.credential,
2486
+ createProtocolEnvelope('delete-cube-create', {
2487
+ retry_key: '00000000-0000-4000-8000-000000000706',
2488
+ name: 'Disposable Cube',
2489
+ working_repo_name: 'disposable-cube',
2490
+ repository: { kind: 'local', value: '00000000-0000-4000-8000-000000000707' },
2491
+ template: 'default',
2492
+ }),
2493
+ );
2494
+ expectStatus(createResponse, 201, 'Disposable cube creation');
2495
+ const created = decodeCreateCubeResponseEnvelope(createResponse.body).payload;
2496
+ const cube = { id: created.cube_id };
2497
+ const role = { id: created.default_worker_role_id };
2498
+ await environment.admin.grantCube(manager.principal, cube, 'manage');
2499
+ await environment.admin.grantCube(reader.principal, cube, 'read');
2500
+ await environment.admin.grantCube(writer.principal, cube, 'write');
2501
+ const drone = await environment.admin.createDrone(creator.principal, cube, role);
2502
+ const droneCredential = await environment.admin.issueManagedDroneSession(drone);
2503
+ const append = await environment.operations.append(
2504
+ creator.credential,
2505
+ cube,
2506
+ createProtocolEnvelope('delete-log', { message: 'deleted with cube' }),
2507
+ );
2508
+ expectStatus(append, 201, 'Deletion fixture log');
2509
+ const entry = decodeAppendLogResultEnvelope(append.body).payload.entry;
2510
+ expectStatus(await environment.operations.ack(
2511
+ creator.credential,
2512
+ cube,
2513
+ createProtocolEnvelope('delete-claim', { entry_id: entry.id, kind: 'claim' }),
2514
+ ), 204, 'Deletion fixture claim');
2515
+ expectStatus(await environment.operations.recordDecision(
2516
+ manager.credential,
2517
+ cube,
2518
+ createProtocolEnvelope('delete-decision', { topic: 'cleanup', decision: 'delete' }),
2519
+ ), 201, 'Deletion fixture decision');
2520
+
2521
+ const beforeDenied = await environment.admin.inspectCubeManagementState(cube);
2522
+ for (const [kind, credential] of [
2523
+ ['read', reader.credential],
2524
+ ['write', writer.credential],
2525
+ ['drone-session', droneCredential],
2526
+ ] as const) {
2527
+ expectError(
2528
+ await environment.operations.deleteCube(
2529
+ credential,
2530
+ cube,
2531
+ createProtocolEnvelope(`delete-${kind}-denied`, {}),
2532
+ ),
2533
+ 403,
2534
+ ErrorCode.ACCESS_DENIED,
2535
+ `${kind} cube deletion`,
2536
+ );
2537
+ invariant(
2538
+ same(await environment.admin.inspectCubeManagementState(cube), beforeDenied),
2539
+ `${kind} cube deletion denial mutated the cube.`,
2540
+ );
2541
+ }
2542
+ expectError(
2543
+ await environment.operations.deleteCube(
2544
+ outsider.credential,
2545
+ cube,
2546
+ createProtocolEnvelope('delete-outsider-denied', {}),
2547
+ ),
2548
+ 404,
2549
+ ErrorCode.NOT_FOUND,
2550
+ 'Never-authorized cube deletion',
2551
+ );
2552
+
2553
+ const parentStream = await environment.operations.openStream(manager.credential, cube, null);
2554
+ const droneStream = await environment.operations.openStream(droneCredential, cube, null);
2555
+ expectStatus(parentStream, 200, 'Deletion parent stream');
2556
+ expectStatus(droneStream, 200, 'Deletion drone stream');
2557
+ invariant(parentStream.stream && droneStream.stream, 'Deletion fixture streams did not open.');
2558
+ const parentEvents = new SseEventReader(parentStream.stream);
2559
+ const droneEvents = new SseEventReader(droneStream.stream);
2560
+ for (const streamReader of [parentEvents, droneEvents]) {
2561
+ invariant((await streamReader.next()).type === 'log', 'Deletion stream omitted replay log.');
2562
+ const bookmark = await streamReader.next();
2563
+ invariant(bookmark.type === 'bookmark' && bookmark.replay_complete, 'Deletion stream omitted bookmark.');
2564
+ }
2565
+ const parentTerminal = parentEvents.next();
2566
+ const droneTerminal = droneEvents.next();
2567
+ await provePending(parentTerminal, 'Parent deletion terminal event', pendingProbeMs);
2568
+ await provePending(droneTerminal, 'Drone deletion terminal event', pendingProbeMs);
2569
+
2570
+ const deleted = await environment.operations.deleteCube(
2571
+ manager.credential,
2572
+ cube,
2573
+ createProtocolEnvelope('delete-managed-cube', {}),
2574
+ );
2575
+ expectStatus(deleted, 200, 'Managed cube deletion');
2576
+ invariant(
2577
+ same(decodeDeleteCubeResponseEnvelope(deleted.body).payload, {
2578
+ cube_id: cube.id,
2579
+ deleted: true,
2580
+ }),
2581
+ 'Cube deletion response did not identify the terminal cube.',
2582
+ );
2583
+ for (const [kind, event] of [
2584
+ ['parent', await within(parentTerminal, 'Parent deletion terminal event', streamDeadlineMs)],
2585
+ ['drone', await within(droneTerminal, 'Drone deletion terminal event', streamDeadlineMs)],
2586
+ ] as const) {
2587
+ invariant(
2588
+ event.type === 'error' && event.error.error.code === ErrorCode.CUBE_DELETED,
2589
+ `${kind} stream did not receive CUBE_DELETED.`,
2590
+ );
2591
+ }
2592
+ for (const streamReader of [parentEvents, droneEvents]) {
2593
+ let closed = false;
2594
+ try {
2595
+ await within(streamReader.next(), 'Deleted cube stream close', streamDeadlineMs);
2596
+ } catch (error) {
2597
+ if (error instanceof Error && error.message.includes('did not settle')) throw error;
2598
+ closed = true;
2599
+ }
2600
+ invariant(closed, 'Deleted cube stream yielded after its terminal error.');
2601
+ await streamReader.close();
2602
+ }
2603
+
2604
+ invariant(
2605
+ same(await environment.admin.inspectDeletedCube(cube), {
2606
+ cube_exists: false,
2607
+ role_count: 0,
2608
+ drone_count: 0,
2609
+ log_count: 0,
2610
+ claim_count: 0,
2611
+ decision_count: 0,
2612
+ grant_count: 0,
2613
+ cube_create_binding_count: 0,
2614
+ repository_association_count: 0,
2615
+ active_stream_count: 0,
2616
+ terminal_credential_count: 5,
2617
+ }),
2618
+ 'Cube deletion did not atomically remove cube-owned state and preserve only terminal credentials.',
2619
+ );
2620
+ for (const [kind, credential] of [
2621
+ ['creator', creator.credential],
2622
+ ['manager', manager.credential],
2623
+ ['reader', reader.credential],
2624
+ ['writer', writer.credential],
2625
+ ['drone', droneCredential],
2626
+ ] as const) {
2627
+ expectError(
2628
+ await environment.operations.read(
2629
+ credential,
2630
+ cube,
2631
+ createProtocolEnvelope(`delete-${kind}-terminal`, { cursor: null, limit: 1 }),
2632
+ ),
2633
+ PROTOCOL_HTTP_CONTRACT.cube_deleted_status,
2634
+ ErrorCode.CUBE_DELETED,
2635
+ `${kind} post-delete request`,
2636
+ );
2637
+ expectError(
2638
+ await environment.operations.deleteCube(
2639
+ credential,
2640
+ cube,
2641
+ createProtocolEnvelope(`delete-${kind}-repeat-terminal`, {}),
2642
+ ),
2643
+ PROTOCOL_HTTP_CONTRACT.cube_deleted_status,
2644
+ ErrorCode.CUBE_DELETED,
2645
+ `${kind} post-delete DELETE`,
2646
+ );
2647
+ }
2648
+ expectError(
2649
+ await environment.operations.deleteCube(
2650
+ outsider.credential,
2651
+ cube,
2652
+ createProtocolEnvelope('delete-outsider-hidden-repeat', {}),
2653
+ ),
2654
+ 404,
2655
+ ErrorCode.NOT_FOUND,
2656
+ 'Never-authorized post-delete DELETE',
2657
+ );
2658
+ expectError(
2659
+ await environment.operations.read(
2660
+ outsider.credential,
2661
+ cube,
2662
+ createProtocolEnvelope('delete-outsider-hidden', { cursor: null, limit: 1 }),
2663
+ ),
2664
+ 404,
2665
+ ErrorCode.NOT_FOUND,
2666
+ 'Never-authorized post-delete request',
2667
+ );
2668
+ await environment.admin.restartAuthority();
2669
+ for (const [kind, credential] of [
2670
+ ['creator', creator.credential],
2671
+ ['manager', manager.credential],
2672
+ ['reader', reader.credential],
2673
+ ['writer', writer.credential],
2674
+ ['drone', droneCredential],
2675
+ ] as const) {
2676
+ expectError(
2677
+ await environment.operations.read(
2678
+ credential,
2679
+ cube,
2680
+ createProtocolEnvelope(`delete-${kind}-after-restart`, { cursor: null, limit: 1 }),
2681
+ ),
2682
+ PROTOCOL_HTTP_CONTRACT.cube_deleted_status,
2683
+ ErrorCode.CUBE_DELETED,
2684
+ `${kind} post-restart deleted-cube request`,
2685
+ );
2686
+ expectError(
2687
+ await environment.operations.deleteCube(
2688
+ credential,
2689
+ cube,
2690
+ createProtocolEnvelope(`delete-${kind}-repeat-after-restart`, {}),
2691
+ ),
2692
+ PROTOCOL_HTTP_CONTRACT.cube_deleted_status,
2693
+ ErrorCode.CUBE_DELETED,
2694
+ `${kind} post-restart DELETE`,
2695
+ );
2696
+ }
2697
+ expectError(
2698
+ await environment.operations.deleteCube(
2699
+ outsider.credential,
2700
+ cube,
2701
+ createProtocolEnvelope('delete-outsider-hidden-repeat-after-restart', {}),
2702
+ ),
2703
+ 404,
2704
+ ErrorCode.NOT_FOUND,
2705
+ 'Never-authorized post-restart DELETE',
2706
+ );
2707
+ expectError(
2708
+ await environment.operations.read(
2709
+ outsider.credential,
2710
+ cube,
2711
+ createProtocolEnvelope('delete-outsider-hidden-after-restart', { cursor: null, limit: 1 }),
2712
+ ),
2713
+ 404,
2714
+ ErrorCode.NOT_FOUND,
2715
+ 'Never-authorized post-restart request',
2716
+ );
2717
+ return {
2718
+ deletion_status: 200,
2719
+ cascade_complete: true,
2720
+ terminal_stream_error: ErrorCode.CUBE_DELETED,
2721
+ terminal_http_status: 410,
2722
+ terminal_state_durable_after_restart: true,
2723
+ unknown_hidden_status: 404,
2724
+ non_member_manager: true,
2725
+ };
2726
+ });
2727
+
2434
2728
  const normalizedTranscript = results.map(({ id, observations }) => ({ id, observations }));
2435
2729
  return { ok: results.every((result) => result.ok), results, normalizedTranscript };
2436
2730
  }
@@ -3,6 +3,7 @@ import type {
3
3
  AttachResponse,
4
4
  AssociateRepositoryCubeRequest,
5
5
  CreateCubeRequest,
6
+ DeleteCubeRequest,
6
7
  EnrollmentExchangeRequest,
7
8
  ResolveRepositoryCubeRequest,
8
9
  } from '../protocol/contract.js';
@@ -443,6 +444,58 @@ readonly CreateCubeAssociationConformanceVector[] = [
443
444
  },
444
445
  ];
445
446
 
447
+ export interface DeleteCubeConformanceVector {
448
+ name: string;
449
+ request: DeleteCubeRequest;
450
+ expected: {
451
+ status: 200 | 403 | 404 | 410;
452
+ error?: 'ACCESS_DENIED' | 'NOT_FOUND' | 'CUBE_DELETED';
453
+ response?: { deleted: true };
454
+ terminal_sse?: { event: 'error'; error: 'CUBE_DELETED'; closes_after_event: true };
455
+ durable_after_restart?: true;
456
+ mutation: 'cascade' | 'none';
457
+ };
458
+ }
459
+
460
+ /** Cube deletion is manage-gated, cascading, terminal, and durable across authority restart. */
461
+ export const DELETE_CUBE_CONFORMANCE: readonly DeleteCubeConformanceVector[] = [
462
+ {
463
+ name: 'non-member managing parent deletes the cube atomically',
464
+ request: {},
465
+ expected: { status: 200, response: { deleted: true }, mutation: 'cascade' },
466
+ },
467
+ {
468
+ name: 'known read and write grants and drone sessions cannot delete',
469
+ request: {},
470
+ expected: { status: 403, error: 'ACCESS_DENIED', mutation: 'none' },
471
+ },
472
+ {
473
+ name: 'never-authorized and unknown callers cannot enumerate the cube',
474
+ request: {},
475
+ expected: { status: 404, error: 'NOT_FOUND', mutation: 'none' },
476
+ },
477
+ {
478
+ name: 'connected clients receive one terminal error frame before close',
479
+ request: {},
480
+ expected: {
481
+ status: 410,
482
+ error: 'CUBE_DELETED',
483
+ terminal_sse: { event: 'error', error: 'CUBE_DELETED', closes_after_event: true },
484
+ mutation: 'none',
485
+ },
486
+ },
487
+ {
488
+ name: 'former authorized credentials retain the typed terminal state after restart',
489
+ request: {},
490
+ expected: {
491
+ status: 410,
492
+ error: 'CUBE_DELETED',
493
+ durable_after_restart: true,
494
+ mutation: 'none',
495
+ },
496
+ },
497
+ ];
498
+
446
499
  export interface ResolveRepositoryCubeConformanceVector {
447
500
  name: string;
448
501
  request: ResolveRepositoryCubeRequest;
@@ -13,12 +13,13 @@ import type {
13
13
  } from './types.js';
14
14
 
15
15
  export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
16
- export const SHARED_PACKAGE_VERSION = '0.7.0' as const;
16
+ export const SHARED_PACKAGE_VERSION = '0.7.1' as const;
17
17
 
18
18
  export const HEALTH_PATH = '/healthz' as const;
19
19
  export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
20
20
  export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
21
21
  export const CUBES_PATH = '/api/cubes' as const;
22
+ export const CUBE_PATH = '/api/cubes/:cubeId' as const;
22
23
  export const REPOSITORY_CUBE_RESOLVE_PATH = '/api/repository-cubes/resolve' as const;
23
24
  export const REPOSITORY_CUBE_ASSOCIATION_PATH = '/api/repository-cubes/association' as const;
24
25
  export const ATTACH_PATH = '/api/client/attach' as const;
@@ -29,6 +30,13 @@ export const PROTOCOL_HTTP_CONTRACT = {
29
30
  protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
30
31
  enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
31
32
  cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
33
+ cube_delete: {
34
+ method: 'DELETE',
35
+ path: CUBE_PATH,
36
+ authenticated: true,
37
+ success_status: 200,
38
+ mutation: true,
39
+ },
32
40
  repository_cube_resolve: {
33
41
  method: 'POST',
34
42
  path: REPOSITORY_CUBE_RESOLVE_PATH,
@@ -68,6 +76,7 @@ export const PROTOCOL_HTTP_CONTRACT = {
68
76
  session_revoked_status: 401,
69
77
  session_rejected_status: 401,
70
78
  cursor_expired_status: 410,
79
+ cube_deleted_status: 410,
71
80
  drone_evicted_status: 410,
72
81
  content_too_large_status: 413,
73
82
  unsupported_protocol_status: 426,
@@ -164,6 +173,13 @@ export interface CreateCubeResponse {
164
173
  access: 'manage';
165
174
  }
166
175
 
176
+ export type DeleteCubeRequest = Record<string, never>;
177
+
178
+ export interface DeleteCubeResponse {
179
+ cube_id: string;
180
+ deleted: true;
181
+ }
182
+
167
183
  export interface ResolveRepositoryCubeRequest {
168
184
  working_repo_name: string;
169
185
  repository: CreateCubeRepository;
@@ -327,7 +343,7 @@ export function decodeProtocolTagPreflight(value: unknown): ProtocolTagPreflight
327
343
  exactKeys(input, ['protocol_version'], ['protocol_version']);
328
344
  if (input.protocol_version !== PROTOCOL_VERSION) {
329
345
  throw new ProtocolContractError(
330
- 'This client requires protocol v6. The peer presents a different version. Update `borgmcp-server` and `borgmcp` to matching releases — server first, then client.',
346
+ 'This client requires protocol v7. The peer presents a different version. Update `borgmcp-server` and `borgmcp` to matching releases — server first, then client.',
331
347
  ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
332
348
  ['protocol_version'],
333
349
  );
@@ -596,6 +612,30 @@ export function decodeCreateCubeResponseEnvelope(value: unknown): ProtocolEnvelo
596
612
  return decodeProtocolEnvelope(value, decodeCreateCubeResponse);
597
613
  }
598
614
 
615
+ export function decodeDeleteCubeRequest(value: unknown): DeleteCubeRequest {
616
+ const input = record(value);
617
+ exactKeys(input, [], []);
618
+ return {};
619
+ }
620
+
621
+ export function decodeDeleteCubeRequestEnvelope(value: unknown): ProtocolEnvelope<DeleteCubeRequest> {
622
+ return decodeProtocolEnvelope(value, decodeDeleteCubeRequest);
623
+ }
624
+
625
+ export function decodeDeleteCubeResponse(value: unknown): DeleteCubeResponse {
626
+ const input = record(value);
627
+ exactKeys(input, ['cube_id', 'deleted'], ['cube_id', 'deleted']);
628
+ if (input.deleted !== true) fail('Cube deletion result must be terminal.', ['deleted']);
629
+ return {
630
+ cube_id: decodeUuid(input.cube_id, ['cube_id']),
631
+ deleted: true,
632
+ };
633
+ }
634
+
635
+ export function decodeDeleteCubeResponseEnvelope(value: unknown): ProtocolEnvelope<DeleteCubeResponse> {
636
+ return decodeProtocolEnvelope(value, decodeDeleteCubeResponse);
637
+ }
638
+
599
639
  export function decodeResolveRepositoryCubeRequest(value: unknown): ResolveRepositoryCubeRequest {
600
640
  const input = record(value);
601
641
  exactKeys(input, ['working_repo_name', 'repository'], ['working_repo_name', 'repository']);
@@ -17,6 +17,7 @@ export enum ErrorCode {
17
17
  CUBE_ALREADY_ASSOCIATED = 'CUBE_ALREADY_ASSOCIATED',
18
18
  ROLE_IN_USE = 'ROLE_IN_USE',
19
19
  ROLE_HAS_FROZEN_DRONES = 'ROLE_HAS_FROZEN_DRONES',
20
+ CUBE_DELETED = 'CUBE_DELETED',
20
21
  DRONE_EVICTED = 'DRONE_EVICTED',
21
22
  DRONE_FROZEN = 'DRONE_FROZEN',
22
23
  UNSUPPORTED_PROTOCOL_VERSION = 'UNSUPPORTED_PROTOCOL_VERSION',
@@ -2,11 +2,13 @@ import type { EnrichedStreamEntry } from './types.js';
2
2
  import {
3
3
  ProtocolContractError,
4
4
  decodeCanonicalTimestamp,
5
+ decodeProtocolErrorEnvelope,
5
6
  decodeLogCursor,
6
7
  decodeOpaqueIdentifier,
7
8
  decodeUuid,
8
9
  utf8ByteLength,
9
10
  type LogCursor,
11
+ type ProtocolErrorEnvelope,
10
12
  } from './contract.js';
11
13
 
12
14
  export const SSE_LIMITS = {
@@ -31,6 +33,7 @@ export type StreamEvent =
31
33
  actor_drone_id: string;
32
34
  occurred_at: string;
33
35
  }
36
+ | StreamErrorEvent
34
37
  | { type: 'heartbeat'; at: string; broadcast_hwm: LogCursor | null }
35
38
  | {
36
39
  type: 'bookmark';
@@ -41,6 +44,12 @@ export type StreamEvent =
41
44
  }
42
45
  | { type: 'unknown'; event: string; raw_data: string };
43
46
 
47
+ /** A terminal stream failure; the server sends this frame once and then closes. */
48
+ export interface StreamErrorEvent {
49
+ type: 'error';
50
+ error: ProtocolErrorEnvelope;
51
+ }
52
+
44
53
  function object(value: unknown): Record<string, unknown> {
45
54
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
46
55
  throw new ProtocolContractError('SSE data must be a JSON object.');
@@ -124,7 +133,7 @@ export function decodeEnrichedStreamEntry(value: unknown): EnrichedStreamEntry {
124
133
 
125
134
  export function encodeSseEvent(event: Exclude<StreamEvent, { type: 'unknown' }>): string {
126
135
  const lines = [`event: ${event.type}`];
127
- let data: Record<string, unknown>;
136
+ let data: unknown;
128
137
  if (event.type === 'log') {
129
138
  const cursor = decodeLogCursor(event.cursor);
130
139
  const entry = decodeEnrichedStreamEntry(event.entry);
@@ -142,6 +151,8 @@ export function encodeSseEvent(event: Exclude<StreamEvent, { type: 'unknown' }>)
142
151
  actor_drone_id: decodeUuid(event.actor_drone_id, ['actor_drone_id']),
143
152
  occurred_at: decodeCanonicalTimestamp(event.occurred_at, ['occurred_at']),
144
153
  };
154
+ } else if (event.type === 'error') {
155
+ data = decodeProtocolErrorEnvelope(event.error);
145
156
  } else if (event.type === 'heartbeat') {
146
157
  data = {
147
158
  at: decodeCanonicalTimestamp(event.at, ['at']),
@@ -217,7 +228,7 @@ function decodeFrame(frame: string): StreamEvent {
217
228
  if (rawDataBytes > SSE_LIMITS.data_bytes) {
218
229
  throw new ProtocolContractError('SSE data exceeds the byte limit.');
219
230
  }
220
- if (!['log', 'ack', 'claim', 'heartbeat', 'bookmark'].includes(eventName)) {
231
+ if (!['log', 'ack', 'claim', 'error', 'heartbeat', 'bookmark'].includes(eventName)) {
221
232
  if (rawDataBytes > SSE_LIMITS.unknown_data_bytes) {
222
233
  throw new ProtocolContractError('Unknown SSE event data exceeds the byte limit.');
223
234
  }
@@ -247,6 +258,10 @@ function decodeFrame(frame: string): StreamEvent {
247
258
  throw new ProtocolContractError(`${eventName} SSE events must not carry a resume id.`);
248
259
  }
249
260
 
261
+ if (eventName === 'error') {
262
+ return { type: 'error', error: decodeProtocolErrorEnvelope(parsed) };
263
+ }
264
+
250
265
  if (eventName === 'ack' || eventName === 'claim') {
251
266
  exactKeys(data, ['log_entry_id', 'actor_drone_id', 'occurred_at'], [
252
267
  'log_entry_id',
@@ -1,4 +1,4 @@
1
- /** Current Borg coordination protocol generation. Clean-slate v6. */
2
- export const PROTOCOL_VERSION = '6' as const;
1
+ /** Current Borg coordination protocol generation. Clean-slate v7. */
2
+ export const PROTOCOL_VERSION = '7' as const;
3
3
 
4
4
  export type ProtocolVersion = typeof PROTOCOL_VERSION;