borgmcp-shared 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ import {
12
12
  type InvitationArtifact,
13
13
  } from '../protocol/contract.js';
14
14
  import type { EnrichedStreamEntry } from '../protocol/types.js';
15
+ import { ROLE_IN_USE_DELETE_MESSAGE } from '../protocol/coordination.js';
15
16
 
16
17
  export * from './adapter.js';
17
18
 
@@ -775,6 +776,144 @@ export interface RuntimeMetadataRepositoryConformanceVector {
775
776
  expected: { working_repo_name: string; working_repo_origin: string } | null;
776
777
  }
777
778
 
779
+ export interface RoleDeleteConformanceVector {
780
+ name: string;
781
+ fixture:
782
+ | 'deletable'
783
+ | 'evicted-drone'
784
+ | 'active-drone'
785
+ | 'default'
786
+ | 'mandatory'
787
+ | 'human-seat'
788
+ | 'taxonomy-reference'
789
+ | 'unknown';
790
+ expected:
791
+ | {
792
+ status: 200;
793
+ response: { deleted: true };
794
+ mutation: 'delete-role';
795
+ evicted_drone_retarget?: 'default-role';
796
+ activity_log_attribution?: 'preserved';
797
+ }
798
+ | {
799
+ status: 409;
800
+ error: 'ROLE_IN_USE' | 'DEFAULT_ROLE_REQUIRED' | 'ROLE_REQUIRED' | 'ROLE_REFERENCED';
801
+ mutation: 'none';
802
+ message?: typeof ROLE_IN_USE_DELETE_MESSAGE;
803
+ }
804
+ | { status: 404; error: 'NOT_FOUND'; mutation: 'none' };
805
+ }
806
+
807
+ /** Portable role-deletion outcomes, including every integrity refusal. */
808
+ export const ROLE_DELETE_CONFORMANCE: readonly RoleDeleteConformanceVector[] = [
809
+ {
810
+ name: 'deletes an unreferenced unassigned ordinary role',
811
+ fixture: 'deletable',
812
+ expected: { status: 200, response: { deleted: true }, mutation: 'delete-role' },
813
+ },
814
+ {
815
+ name: 'retargets evicted drones without losing existing log attribution',
816
+ fixture: 'evicted-drone',
817
+ expected: {
818
+ status: 200,
819
+ response: { deleted: true },
820
+ mutation: 'delete-role',
821
+ evicted_drone_retarget: 'default-role',
822
+ activity_log_attribution: 'preserved',
823
+ },
824
+ },
825
+ {
826
+ name: 'refuses a role held by an active drone with an actionable message',
827
+ fixture: 'active-drone',
828
+ expected: {
829
+ status: 409,
830
+ error: 'ROLE_IN_USE',
831
+ mutation: 'none',
832
+ message: ROLE_IN_USE_DELETE_MESSAGE,
833
+ },
834
+ },
835
+ {
836
+ name: 'refuses the cube default role',
837
+ fixture: 'default',
838
+ expected: { status: 409, error: 'DEFAULT_ROLE_REQUIRED', mutation: 'none' },
839
+ },
840
+ {
841
+ name: 'refuses a mandatory role',
842
+ fixture: 'mandatory',
843
+ expected: { status: 409, error: 'ROLE_REQUIRED', mutation: 'none' },
844
+ },
845
+ {
846
+ name: 'refuses a human-seat role',
847
+ fixture: 'human-seat',
848
+ expected: { status: 409, error: 'ROLE_REQUIRED', mutation: 'none' },
849
+ },
850
+ {
851
+ name: 'refuses a taxonomy default-recipient role',
852
+ fixture: 'taxonomy-reference',
853
+ expected: { status: 409, error: 'ROLE_REFERENCED', mutation: 'none' },
854
+ },
855
+ {
856
+ name: 'hides an unknown or inaccessible role',
857
+ fixture: 'unknown',
858
+ expected: { status: 404, error: 'NOT_FOUND', mutation: 'none' },
859
+ },
860
+ ];
861
+
862
+ export interface RoleRationaleConformanceVector {
863
+ name: string;
864
+ fixture:
865
+ | 'uuid-exact'
866
+ | 'name-case-insensitive'
867
+ | 'unknown-role'
868
+ | 'inaccessible-role'
869
+ | 'unknown-section'
870
+ | 'ambiguous-role-name'
871
+ | 'invalid-selector';
872
+ expected:
873
+ | { status: 200; canonical_heading: true; exact_body: true; mutation: 'none' }
874
+ | { status: 404; error: 'ROLE_NOT_FOUND' | 'ROLE_SECTION_NOT_FOUND'; mutation: 'none' }
875
+ | { status: 400; error: 'INVALID_INPUT'; mutation: 'none' };
876
+ }
877
+
878
+ /** Portable role-rationale lookup outcomes for the named-section route. */
879
+ export const ROLE_RATIONALE_CONFORMANCE: readonly RoleRationaleConformanceVector[] = [
880
+ {
881
+ name: 'resolves an exact role UUID and returns the exact stored section',
882
+ fixture: 'uuid-exact',
883
+ expected: { status: 200, canonical_heading: true, exact_body: true, mutation: 'none' },
884
+ },
885
+ {
886
+ name: 'resolves a role name and section heading case-insensitively',
887
+ fixture: 'name-case-insensitive',
888
+ expected: { status: 200, canonical_heading: true, exact_body: true, mutation: 'none' },
889
+ },
890
+ {
891
+ name: 'returns a typed refusal for an unknown role',
892
+ fixture: 'unknown-role',
893
+ expected: { status: 404, error: 'ROLE_NOT_FOUND', mutation: 'none' },
894
+ },
895
+ {
896
+ name: 'does not resolve a role outside the addressed cube',
897
+ fixture: 'inaccessible-role',
898
+ expected: { status: 404, error: 'ROLE_NOT_FOUND', mutation: 'none' },
899
+ },
900
+ {
901
+ name: 'returns a typed refusal for an unknown section',
902
+ fixture: 'unknown-section',
903
+ expected: { status: 404, error: 'ROLE_SECTION_NOT_FOUND', mutation: 'none' },
904
+ },
905
+ {
906
+ name: 'rejects a role name with multiple case-insensitive matches',
907
+ fixture: 'ambiguous-role-name',
908
+ expected: { status: 400, error: 'INVALID_INPUT', mutation: 'none' },
909
+ },
910
+ {
911
+ name: 'rejects malformed or ambiguous selectors',
912
+ fixture: 'invalid-selector',
913
+ expected: { status: 400, error: 'INVALID_INPUT', mutation: 'none' },
914
+ },
915
+ ];
916
+
778
917
  /** One canonical corpus consumed unchanged by shared, server, and client tests. */
779
918
  export const RUNTIME_METADATA_REPOSITORY_CONFORMANCE:
780
919
  readonly RuntimeMetadataRepositoryConformanceVector[] = [
@@ -13,15 +13,19 @@ 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.9.0' as const;
16
+ export const SHARED_PACKAGE_VERSION = '0.10.0' as const;
17
17
  /** Maximum UTF-8 payload for each newly recorded decision text field. */
18
18
  export const DECISION_TEXT_MAX_BYTES = 512 as const;
19
+ /** Maximum UTF-8 size of role detailed-description text and any returned section slice. */
20
+ export const ROLE_TEXT_MAX_BYTES = 51_200 as const;
19
21
 
20
22
  export const HEALTH_PATH = '/healthz' as const;
21
23
  export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
22
24
  export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
23
25
  export const CUBES_PATH = '/api/cubes' as const;
24
26
  export const CUBE_PATH = '/api/cubes/:cubeId' as const;
27
+ export const ROLE_PATH = '/api/cubes/:cubeId/roles/:roleId' as const;
28
+ export const ROLE_RATIONALE_PATH = '/api/cubes/:cubeId/role-rationale' as const;
25
29
  export const REPOSITORY_CUBE_RESOLVE_PATH = '/api/repository-cubes/resolve' as const;
26
30
  export const REPOSITORY_CUBE_ASSOCIATION_PATH = '/api/repository-cubes/association' as const;
27
31
  export const ATTACH_PATH = '/api/client/attach' as const;
@@ -39,6 +43,20 @@ export const PROTOCOL_HTTP_CONTRACT = {
39
43
  success_status: 200,
40
44
  mutation: true,
41
45
  },
46
+ role_delete: {
47
+ method: 'DELETE',
48
+ path: ROLE_PATH,
49
+ authenticated: true,
50
+ success_status: 200,
51
+ mutation: true,
52
+ },
53
+ role_rationale: {
54
+ method: 'POST',
55
+ path: ROLE_RATIONALE_PATH,
56
+ authenticated: true,
57
+ success_status: 200,
58
+ mutation: false,
59
+ },
42
60
  repository_cube_resolve: {
43
61
  method: 'POST',
44
62
  path: REPOSITORY_CUBE_RESOLVE_PATH,
@@ -563,7 +581,7 @@ export function decodeProtocolTagPreflight(value: unknown): ProtocolTagPreflight
563
581
  exactKeys(input, ['protocol_version'], ['protocol_version']);
564
582
  if (input.protocol_version !== PROTOCOL_VERSION) {
565
583
  throw new ProtocolContractError(
566
- 'This client requires protocol v7. The peer presents a different version. Update `borgmcp-server` and `borgmcp` to matching releases — server first, then client.',
584
+ 'This client requires protocol v8. The peer presents a different version. Update `borgmcp-server` and `borgmcp` to matching releases — server first, then client.',
567
585
  ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
568
586
  ['protocol_version'],
569
587
  );
@@ -5,6 +5,7 @@ import {
5
5
  decodeLogCursor,
6
6
  decodeProtocolEnvelope,
7
7
  decodeUuid,
8
+ ROLE_TEXT_MAX_BYTES,
8
9
  utf8ByteLength,
9
10
  type LogCursor,
10
11
  type ProtocolEnvelope,
@@ -16,6 +17,7 @@ import type {
16
17
  EnrichedStreamEntry,
17
18
  RoutingEcho,
18
19
  } from './types.js';
20
+ import { isLabelLine, parseRoleSections } from '../role-section.js';
19
21
 
20
22
  export interface ReadLogRequest {
21
23
  cursor: LogCursor | null;
@@ -65,6 +67,34 @@ export interface EvictDroneResult {
65
67
  evicted: true;
66
68
  }
67
69
 
70
+ export type DeleteRoleRequest = Record<string, never>;
71
+
72
+ export const ROLE_IN_USE_DELETE_MESSAGE =
73
+ 'Reassign or evict every drone assigned to this role before deleting it.' as const;
74
+
75
+ export interface DeleteRoleResult {
76
+ role_id: string;
77
+ deleted: true;
78
+ }
79
+
80
+ export interface RoleRationaleRequest {
81
+ role: string;
82
+ section: string;
83
+ }
84
+
85
+ export interface RoleRationaleResult {
86
+ role_id: string;
87
+ role_name: string;
88
+ section: {
89
+ heading: string;
90
+ body: string;
91
+ };
92
+ }
93
+
94
+ /** Maximum UTF-8 payload for one returned role-description section. */
95
+ export const ROLE_RATIONALE_SECTION_BODY_MAX_BYTES =
96
+ ROLE_TEXT_MAX_BYTES;
97
+
68
98
  function object(value: unknown): Record<string, unknown> {
69
99
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
70
100
  throw new ProtocolContractError('Expected a coordination object.');
@@ -171,6 +201,97 @@ export function decodeEvictDroneResultEnvelope(
171
201
  return decodeProtocolEnvelope(value, decodeEvictDroneResult);
172
202
  }
173
203
 
204
+ export function decodeDeleteRoleRequest(value: unknown): DeleteRoleRequest {
205
+ const input = object(value);
206
+ exact(input, [], []);
207
+ return {};
208
+ }
209
+
210
+ export function decodeDeleteRoleRequestEnvelope(
211
+ value: unknown,
212
+ ): ProtocolEnvelope<DeleteRoleRequest> {
213
+ return decodeProtocolEnvelope(value, decodeDeleteRoleRequest);
214
+ }
215
+
216
+ export function decodeDeleteRoleResult(value: unknown): DeleteRoleResult {
217
+ const input = object(value);
218
+ exact(input, ['role_id', 'deleted'], ['role_id', 'deleted']);
219
+ if (input.deleted !== true) throw new ProtocolContractError('Invalid role deletion result.');
220
+ return { role_id: decodeUuid(input.role_id, ['role_id']), deleted: true };
221
+ }
222
+
223
+ export function decodeDeleteRoleResultEnvelope(
224
+ value: unknown,
225
+ ): ProtocolEnvelope<DeleteRoleResult> {
226
+ return decodeProtocolEnvelope(value, decodeDeleteRoleResult);
227
+ }
228
+
229
+ function plainRoleSectionHeading(value: unknown): string {
230
+ const heading = boundedString(value, 'section', 60);
231
+ if (
232
+ heading !== heading.trim() ||
233
+ /[\u0000-\u001f\u007f-\u009f]/.test(heading) ||
234
+ !isLabelLine(`${heading}:`)
235
+ ) {
236
+ throw new ProtocolContractError('Invalid coordination field "section".');
237
+ }
238
+ return heading;
239
+ }
240
+
241
+ export function decodeRoleRationaleRequest(value: unknown): RoleRationaleRequest {
242
+ const input = object(value);
243
+ exact(input, ['role', 'section'], ['role', 'section']);
244
+ const role = boundedString(input.role, 'role', 120);
245
+ if (role !== role.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(role)) {
246
+ throw new ProtocolContractError('Invalid coordination field "role".');
247
+ }
248
+ return { role, section: plainRoleSectionHeading(input.section) };
249
+ }
250
+
251
+ export function decodeRoleRationaleRequestEnvelope(
252
+ value: unknown,
253
+ ): ProtocolEnvelope<RoleRationaleRequest> {
254
+ return decodeProtocolEnvelope(value, decodeRoleRationaleRequest);
255
+ }
256
+
257
+ export function decodeRoleRationaleResult(value: unknown): RoleRationaleResult {
258
+ const input = object(value);
259
+ exact(input, ['role_id', 'role_name', 'section'], ['role_id', 'role_name', 'section']);
260
+ const section = object(input.section);
261
+ exact(section, ['heading', 'body'], ['heading', 'body']);
262
+ const heading = plainRoleSectionHeading(section.heading);
263
+ const body = boundedString(
264
+ section.body,
265
+ 'section.body',
266
+ ROLE_RATIONALE_SECTION_BODY_MAX_BYTES,
267
+ );
268
+ const parsed = parseRoleSections(body);
269
+ if (
270
+ parsed.length !== 2 ||
271
+ parsed[0]?.kind !== 'preamble' ||
272
+ parsed[0].body !== '' ||
273
+ parsed[1]?.kind !== 'label' ||
274
+ parsed[1].heading !== heading ||
275
+ parsed[1].body !== body
276
+ ) {
277
+ throw new ProtocolContractError('Invalid coordination field "section.body".');
278
+ }
279
+ return {
280
+ role_id: decodeUuid(input.role_id, ['role_id']),
281
+ role_name: boundedString(input.role_name, 'role_name', 120),
282
+ section: {
283
+ heading,
284
+ body,
285
+ },
286
+ };
287
+ }
288
+
289
+ export function decodeRoleRationaleResultEnvelope(
290
+ value: unknown,
291
+ ): ProtocolEnvelope<RoleRationaleResult> {
292
+ return decodeProtocolEnvelope(value, decodeRoleRationaleResult);
293
+ }
294
+
174
295
  export function decodeReadLogRequest(value: unknown): ReadLogRequest {
175
296
  const input = object(value);
176
297
  exact(input, ['cursor', 'limit'], ['cursor']);
@@ -16,6 +16,11 @@ export enum ErrorCode {
16
16
  REPOSITORY_ALREADY_ASSOCIATED = 'REPOSITORY_ALREADY_ASSOCIATED',
17
17
  CUBE_ALREADY_ASSOCIATED = 'CUBE_ALREADY_ASSOCIATED',
18
18
  ROLE_IN_USE = 'ROLE_IN_USE',
19
+ DEFAULT_ROLE_REQUIRED = 'DEFAULT_ROLE_REQUIRED',
20
+ ROLE_REQUIRED = 'ROLE_REQUIRED',
21
+ ROLE_REFERENCED = 'ROLE_REFERENCED',
22
+ ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
23
+ ROLE_SECTION_NOT_FOUND = 'ROLE_SECTION_NOT_FOUND',
19
24
  ROLE_HAS_FROZEN_DRONES = 'ROLE_HAS_FROZEN_DRONES',
20
25
  CUBE_DELETED = 'CUBE_DELETED',
21
26
  DRONE_EVICTED = 'DRONE_EVICTED',
@@ -1,4 +1,4 @@
1
- /** Current Borg coordination protocol generation. Clean-slate v7. */
2
- export const PROTOCOL_VERSION = '7' as const;
1
+ /** Current Borg coordination protocol generation. Clean-slate v8. */
2
+ export const PROTOCOL_VERSION = '8' as const;
3
3
 
4
4
  export type ProtocolVersion = typeof PROTOCOL_VERSION;