borgmcp-shared 0.6.4 → 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.
Files changed (44) hide show
  1. package/README.md +5 -23
  2. package/RELEASES.md +29 -0
  3. package/dist/conformance/adapter.d.ts +19 -0
  4. package/dist/conformance/adapter.d.ts.map +1 -1
  5. package/dist/conformance/adapter.js +141 -1
  6. package/dist/conformance/adapter.js.map +1 -1
  7. package/dist/conformance/index.d.ts +32 -1
  8. package/dist/conformance/index.d.ts.map +1 -1
  9. package/dist/conformance/index.js +167 -0
  10. package/dist/conformance/index.js.map +1 -1
  11. package/dist/protocol/contract.d.ts +20 -2
  12. package/dist/protocol/contract.d.ts.map +1 -1
  13. package/dist/protocol/contract.js +33 -3
  14. package/dist/protocol/contract.js.map +1 -1
  15. package/dist/protocol/coordination.d.ts +2 -2
  16. package/dist/protocol/coordination.d.ts.map +1 -1
  17. package/dist/protocol/coordination.js +36 -2
  18. package/dist/protocol/coordination.js.map +1 -1
  19. package/dist/protocol/errors.d.ts +1 -0
  20. package/dist/protocol/errors.d.ts.map +1 -1
  21. package/dist/protocol/errors.js +1 -0
  22. package/dist/protocol/errors.js.map +1 -1
  23. package/dist/protocol/sse.d.ts +6 -2
  24. package/dist/protocol/sse.d.ts.map +1 -1
  25. package/dist/protocol/sse.js +8 -2
  26. package/dist/protocol/sse.js.map +1 -1
  27. package/dist/protocol/version.d.ts +1 -1
  28. package/dist/protocol/version.js +1 -1
  29. package/dist/templates.d.ts +4 -0
  30. package/dist/templates.d.ts.map +1 -1
  31. package/dist/templates.js +182 -1
  32. package/dist/templates.js.map +1 -1
  33. package/docs/compatibility.md +16 -4
  34. package/docs/enrollment.md +14 -7
  35. package/docs/releasing.md +15 -9
  36. package/package.json +2 -1
  37. package/src/conformance/adapter.ts +294 -0
  38. package/src/conformance/index.ts +203 -0
  39. package/src/protocol/contract.ts +43 -3
  40. package/src/protocol/coordination.ts +53 -4
  41. package/src/protocol/errors.ts +1 -0
  42. package/src/protocol/sse.ts +17 -2
  43. package/src/protocol/version.ts +2 -2
  44. package/src/templates.ts +188 -1
@@ -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,9 +3,11 @@ 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';
10
+ import type { EnrichedStreamEntry } from '../protocol/types.js';
9
11
 
10
12
  export * from './adapter.js';
11
13
 
@@ -67,6 +69,138 @@ export const DRONE_ADDRESS_CONFORMANCE: readonly ConformanceVector<string, strin
67
69
  },
68
70
  ];
69
71
 
72
+ const APPEND_LOG_ENTRY: EnrichedStreamEntry = {
73
+ id: '00000000-0000-4000-8000-000000000201',
74
+ cube_id: '00000000-0000-4000-8000-000000000202',
75
+ drone_id: '00000000-0000-4000-8000-000000000203',
76
+ message: 'REVIEW-READY: example',
77
+ visibility: 'direct',
78
+ created_at: '2026-01-01T00:00:00.000Z',
79
+ drone_label: 'one-of-one-builder',
80
+ role_name: 'Builder',
81
+ recipient_drone_ids: ['00000000-0000-4000-8000-000000000204'],
82
+ };
83
+
84
+ const APPEND_LOG_ROUTING = {
85
+ class: 'review',
86
+ recipients: ['one-of-one-reviewer'],
87
+ fellOpen: false,
88
+ message: 'Routing applied.',
89
+ } as const;
90
+
91
+ export interface AppendLogResultConformanceVector {
92
+ name: string;
93
+ response: unknown;
94
+ accepts: boolean;
95
+ }
96
+
97
+ /** Runtime response vectors keep the append-log decoder aligned with its public type. */
98
+ export const APPEND_LOG_RESULT_CONFORMANCE: readonly AppendLogResultConformanceVector[] = [
99
+ {
100
+ name: 'accepts a response without optional routing metadata',
101
+ response: { entry: APPEND_LOG_ENTRY },
102
+ accepts: true,
103
+ },
104
+ {
105
+ name: 'accepts exact routing metadata and unreachable recipients',
106
+ response: {
107
+ entry: APPEND_LOG_ENTRY,
108
+ routing: APPEND_LOG_ROUTING,
109
+ unreachableRecipients: [{ id: 'missing-reviewer', label: 'Missing Reviewer' }],
110
+ },
111
+ accepts: true,
112
+ },
113
+ {
114
+ name: 'accepts a null routing echo and an empty unreachable-recipient list',
115
+ response: { entry: APPEND_LOG_ENTRY, routing: null, unreachableRecipients: [] },
116
+ accepts: true,
117
+ },
118
+ {
119
+ name: 'rejects unknown top-level response fields',
120
+ response: { entry: APPEND_LOG_ENTRY, routing: APPEND_LOG_ROUTING, extra: true },
121
+ accepts: false,
122
+ },
123
+ {
124
+ name: 'rejects a non-object routing echo',
125
+ response: { entry: APPEND_LOG_ENTRY, routing: 'review' },
126
+ accepts: false,
127
+ },
128
+ {
129
+ name: 'rejects missing routing fields',
130
+ response: { entry: APPEND_LOG_ENTRY, routing: { class: 'review' } },
131
+ accepts: false,
132
+ },
133
+ {
134
+ name: 'rejects unknown routing fields',
135
+ response: { entry: APPEND_LOG_ENTRY, routing: { ...APPEND_LOG_ROUTING, extra: true } },
136
+ accepts: false,
137
+ },
138
+ {
139
+ name: 'rejects an invalid routing class',
140
+ response: { entry: APPEND_LOG_ENTRY, routing: { ...APPEND_LOG_ROUTING, class: 7 } },
141
+ accepts: false,
142
+ },
143
+ {
144
+ name: 'rejects an invalid routing recipient collection',
145
+ response: { entry: APPEND_LOG_ENTRY, routing: { ...APPEND_LOG_ROUTING, recipients: 'reviewer' } },
146
+ accepts: false,
147
+ },
148
+ {
149
+ name: 'rejects invalid routing recipient members',
150
+ response: { entry: APPEND_LOG_ENTRY, routing: { ...APPEND_LOG_ROUTING, recipients: [7] } },
151
+ accepts: false,
152
+ },
153
+ {
154
+ name: 'rejects an invalid routing fell-open flag',
155
+ response: { entry: APPEND_LOG_ENTRY, routing: { ...APPEND_LOG_ROUTING, fellOpen: 'false' } },
156
+ accepts: false,
157
+ },
158
+ {
159
+ name: 'rejects an invalid routing message',
160
+ response: { entry: APPEND_LOG_ENTRY, routing: { ...APPEND_LOG_ROUTING, message: 7 } },
161
+ accepts: false,
162
+ },
163
+ {
164
+ name: 'rejects a non-array unreachable-recipient list',
165
+ response: { entry: APPEND_LOG_ENTRY, unreachableRecipients: {} },
166
+ accepts: false,
167
+ },
168
+ {
169
+ name: 'rejects a non-object unreachable recipient',
170
+ response: { entry: APPEND_LOG_ENTRY, unreachableRecipients: ['missing-reviewer'] },
171
+ accepts: false,
172
+ },
173
+ {
174
+ name: 'rejects missing unreachable-recipient fields',
175
+ response: { entry: APPEND_LOG_ENTRY, unreachableRecipients: [{ id: 'missing-reviewer' }] },
176
+ accepts: false,
177
+ },
178
+ {
179
+ name: 'rejects unknown unreachable-recipient fields',
180
+ response: {
181
+ entry: APPEND_LOG_ENTRY,
182
+ unreachableRecipients: [{ id: 'missing-reviewer', label: 'Missing Reviewer', extra: true }],
183
+ },
184
+ accepts: false,
185
+ },
186
+ {
187
+ name: 'rejects an invalid unreachable-recipient id',
188
+ response: {
189
+ entry: APPEND_LOG_ENTRY,
190
+ unreachableRecipients: [{ id: 7, label: 'Missing Reviewer' }],
191
+ },
192
+ accepts: false,
193
+ },
194
+ {
195
+ name: 'rejects an invalid unreachable-recipient label',
196
+ response: {
197
+ entry: APPEND_LOG_ENTRY,
198
+ unreachableRecipients: [{ id: 'missing-reviewer', label: null }],
199
+ },
200
+ accepts: false,
201
+ },
202
+ ];
203
+
70
204
  export const ROLE_SECTION_ROUND_TRIP_CONFORMANCE: readonly string[] = [
71
205
  '',
72
206
  'Preamble only.',
@@ -191,6 +325,23 @@ const CREATE_CUBE_INITIAL: CreateCubeRequest = {
191
325
  template: 'default',
192
326
  };
193
327
 
328
+ export interface CubeTemplateAcceptanceConformanceVector {
329
+ name: string;
330
+ template: unknown;
331
+ accepts: boolean;
332
+ }
333
+
334
+ /** Protocol v6's complete closed acceptance set for cube-creation templates. */
335
+ export const CUBE_TEMPLATE_ACCEPTANCE_CONFORMANCE:
336
+ readonly CubeTemplateAcceptanceConformanceVector[] = [
337
+ { name: 'accepts the legacy default template', template: 'default', accepts: true },
338
+ { name: 'accepts the software-development template', template: 'software-dev', accepts: true },
339
+ { name: 'accepts the starter template', template: 'starter', accepts: true },
340
+ { name: 'accepts the local-model template', template: 'local-model', accepts: true },
341
+ { name: 'rejects an unknown template name', template: 'custom', accepts: false },
342
+ { name: 'rejects a non-string template', template: null, accepts: false },
343
+ ];
344
+
194
345
  /** Stateful vectors: name, repository identity, and template bind the retry key. */
195
346
  export const CREATE_CUBE_RETRY_CONFORMANCE: readonly CreateCubeRetryConformanceVector[] = [
196
347
  {
@@ -293,6 +444,58 @@ readonly CreateCubeAssociationConformanceVector[] = [
293
444
  },
294
445
  ];
295
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
+
296
499
  export interface ResolveRepositoryCubeConformanceVector {
297
500
  name: string;
298
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.6.4' 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,
@@ -137,7 +146,7 @@ export type EnrollmentExchangeResponse =
137
146
  | ClientEnrollmentExchangeResponse
138
147
  | OwnerEnrollmentExchangeResponse;
139
148
 
140
- export const CUBE_TEMPLATES = ['default', 'software-dev', 'starter'] as const;
149
+ export const CUBE_TEMPLATES = ['default', 'software-dev', 'starter', 'local-model'] as const;
141
150
  export type CubeTemplate = (typeof CUBE_TEMPLATES)[number];
142
151
 
143
152
  export type CreateCubeRepository =
@@ -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
- 'Unsupported protocol version.',
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']);