@company-semantics/contracts 45.0.0 → 45.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "45.0.0",
3
+ "version": "45.2.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -116,6 +116,7 @@
116
116
  "generate:current": "tsx ../company-semantics-ci/scripts/generate-current.ts",
117
117
  "adr:new": "tsx ../company-semantics-control/scripts/adr/adr-new-cli.ts",
118
118
  "repo-map": "tsx ../company-semantics-ci/scripts/generate-repo-map.ts --roots src --name company-semantics-contracts --write",
119
+ "sync:workflow-pins": "tsx ../company-semantics-ci/scripts/sync-workflow-pins.ts --write",
119
120
  "repo-map:check": "tsx ../company-semantics-ci/scripts/generate-repo-map.ts --roots src --name company-semantics-contracts --check",
120
121
  "readme-api": "tsx ../company-semantics-ci/scripts/generate-readme-api.ts --roots src --write --jsdoc",
121
122
  "readme-api:check": "tsx ../company-semantics-ci/scripts/generate-readme-api.ts --roots src --check --jsdoc",
@@ -134,7 +135,7 @@
134
135
  "amphtml-validator": "^1.0.38",
135
136
  "culori": "^4.0.2",
136
137
  "husky": "^9.1.7",
137
- "lint-staged": "^17.2.0",
138
+ "lint-staged": "^17.3.0",
138
139
  "markdownlint-cli2": "^0.23.2",
139
140
  "openapi-typescript": "^7.13.0",
140
141
  "prettier": "^3.9.6",
@@ -146,9 +147,9 @@
146
147
  },
147
148
  "pnpm": {
148
149
  "overrides": {
149
- "brace-expansion@<5.0.8": ">=5.0.8",
150
+ "brace-expansion@<5.0.9": ">=5.0.9",
150
151
  "minimatch@<10.2.3": ">=10.2.3",
151
- "js-yaml@>=4.0.0 <4.3.0": ">=4.3.0 <5.0.0",
152
+ "js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5.0.0",
152
153
  "js-yaml@>=5.0.0 <5.2.2": ">=5.2.2 <6.0.0",
153
154
  "picomatch@<4.0.4": ">=4.0.4",
154
155
  "esbuild@<0.28.1": ">=0.28.1",
@@ -162,5 +163,5 @@
162
163
  "*.md": "markdownlint-cli2",
163
164
  "package.json": "node -e \"JSON.parse(require('fs').readFileSync('package.json'))\""
164
165
  },
165
- "securityRequirementsVersion": "9ffb1b974fa033f13a73539f8338cad319b62bb95695aa4ac7b5215a50493513"
166
+ "securityRequirementsVersion": "795d779110bb8dee6374229e227dc7b7e813197c34a35bbe2066113e0939a988"
166
167
  }
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = 'b10dd357bc4f' as const;
3
- export const SPEC_HASH_FULL = 'b10dd357bc4fd990f9d6646954b3b1aa38f9dce4dd64ecfe8f5f83eb8f51a568' as const;
2
+ export const SPEC_HASH = '7f497f657ee3' as const;
3
+ export const SPEC_HASH_FULL = '7f497f657ee3f1a81b096b02094743f5e0fa6a3d03c0bb92cdb0034762ca3ee7' as const;
@@ -3394,6 +3394,109 @@ export interface paths {
3394
3394
  patch?: never;
3395
3395
  trace?: never;
3396
3396
  };
3397
+ "/api/comments": {
3398
+ parameters: {
3399
+ query?: never;
3400
+ header?: never;
3401
+ path?: never;
3402
+ cookie?: never;
3403
+ };
3404
+ /** List a subject's comment threads */
3405
+ get: operations["listCommentThreads"];
3406
+ put?: never;
3407
+ post?: never;
3408
+ delete?: never;
3409
+ options?: never;
3410
+ head?: never;
3411
+ patch?: never;
3412
+ trace?: never;
3413
+ };
3414
+ "/api/comments/threads": {
3415
+ parameters: {
3416
+ query?: never;
3417
+ header?: never;
3418
+ path?: never;
3419
+ cookie?: never;
3420
+ };
3421
+ get?: never;
3422
+ put?: never;
3423
+ /** Open a comment thread on a subject */
3424
+ post: operations["createCommentThread"];
3425
+ delete?: never;
3426
+ options?: never;
3427
+ head?: never;
3428
+ patch?: never;
3429
+ trace?: never;
3430
+ };
3431
+ "/api/comments/threads/{threadId}/comments": {
3432
+ parameters: {
3433
+ query?: never;
3434
+ header?: never;
3435
+ path?: never;
3436
+ cookie?: never;
3437
+ };
3438
+ get?: never;
3439
+ put?: never;
3440
+ /** Reply into a comment thread */
3441
+ post: operations["replyToCommentThread"];
3442
+ delete?: never;
3443
+ options?: never;
3444
+ head?: never;
3445
+ patch?: never;
3446
+ trace?: never;
3447
+ };
3448
+ "/api/comments/{commentId}": {
3449
+ parameters: {
3450
+ query?: never;
3451
+ header?: never;
3452
+ path?: never;
3453
+ cookie?: never;
3454
+ };
3455
+ get?: never;
3456
+ put?: never;
3457
+ post?: never;
3458
+ /** Soft-delete a comment (author or subject owner) */
3459
+ delete: operations["deleteComment"];
3460
+ options?: never;
3461
+ head?: never;
3462
+ /** Edit a comment's body (author-only) */
3463
+ patch: operations["editComment"];
3464
+ trace?: never;
3465
+ };
3466
+ "/api/comments/threads/{threadId}/resolve": {
3467
+ parameters: {
3468
+ query?: never;
3469
+ header?: never;
3470
+ path?: never;
3471
+ cookie?: never;
3472
+ };
3473
+ get?: never;
3474
+ put?: never;
3475
+ /** Resolve a comment thread */
3476
+ post: operations["resolveCommentThread"];
3477
+ delete?: never;
3478
+ options?: never;
3479
+ head?: never;
3480
+ patch?: never;
3481
+ trace?: never;
3482
+ };
3483
+ "/api/comments/threads/{threadId}/reopen": {
3484
+ parameters: {
3485
+ query?: never;
3486
+ header?: never;
3487
+ path?: never;
3488
+ cookie?: never;
3489
+ };
3490
+ get?: never;
3491
+ put?: never;
3492
+ /** Reopen a resolved comment thread */
3493
+ post: operations["reopenCommentThread"];
3494
+ delete?: never;
3495
+ options?: never;
3496
+ head?: never;
3497
+ patch?: never;
3498
+ trace?: never;
3499
+ };
3397
3500
  }
3398
3501
  export type webhooks = Record<string, never>;
3399
3502
  export interface components {
@@ -4036,6 +4139,10 @@ export interface components {
4036
4139
  unitMembershipsTruncated: boolean;
4037
4140
  inviteStatus: ("active" | "pending" | "expired") | null;
4038
4141
  effectiveScopes: string[];
4142
+ scopeProvenance?: {
4143
+ scope: string;
4144
+ bands: string[];
4145
+ }[];
4039
4146
  recentActions: {
4040
4147
  id: string;
4041
4148
  timestamp: string;
@@ -6156,6 +6263,41 @@ export interface components {
6156
6263
  userAgent: string;
6157
6264
  } | null;
6158
6265
  };
6266
+ /** @description Every thread on the subject the caller may see. */
6267
+ CommentThreadListResponse: {
6268
+ threads: components["schemas"]["CommentThread"][];
6269
+ };
6270
+ /** @description The created thread with its opening comment. */
6271
+ CommentThreadResponse: {
6272
+ thread: components["schemas"]["CommentThread"];
6273
+ };
6274
+ CreateCommentThreadRequest: {
6275
+ /** @enum {string} */
6276
+ subjectType: "company_md" | "strategy_doc" | "work_item" | "meeting_recording";
6277
+ /** Format: uuid */
6278
+ subjectId: string;
6279
+ anchor: components["schemas"]["CommentAnchor"];
6280
+ body: string;
6281
+ };
6282
+ /** @description The written reply, and whether it auto-reopened a resolved thread. */
6283
+ CommentReplyResponse: {
6284
+ comment: components["schemas"]["Comment"];
6285
+ reopened: boolean;
6286
+ };
6287
+ CommentReplyRequest: {
6288
+ body: string;
6289
+ };
6290
+ /** @description The comment after the mutation (redacted projection after delete). */
6291
+ CommentResponse: {
6292
+ comment: components["schemas"]["Comment"];
6293
+ };
6294
+ EditCommentRequest: {
6295
+ body: string;
6296
+ };
6297
+ /** @description The thread after a resolve/reopen transition. */
6298
+ CommentThreadSummaryResponse: {
6299
+ thread: components["schemas"]["CommentThreadSummary"];
6300
+ };
6159
6301
  /** @description Server-enforced collaboration size limits, published so clients can pre-check. */
6160
6302
  CollabSyncLimits: {
6161
6303
  maxUpdateBytes: number;
@@ -6174,6 +6316,69 @@ export interface components {
6174
6316
  /** @enum {string} */
6175
6317
  matchedVia: "fts" | "vector";
6176
6318
  };
6319
+ /** @description A comment thread with all its comments oldest-first; soft-deleted comments appear redacted, never filtered out. */
6320
+ CommentThread: {
6321
+ id: string;
6322
+ /** @enum {string} */
6323
+ subjectType: "company_md" | "strategy_doc" | "work_item" | "meeting_recording";
6324
+ subjectId: string;
6325
+ /** @enum {string} */
6326
+ anchorType: "document" | "text";
6327
+ anchor: components["schemas"]["CommentAnchor"];
6328
+ /** @enum {string} */
6329
+ status: "open" | "resolved";
6330
+ createdByUserId: string | null;
6331
+ resolvedByUserId: string | null;
6332
+ resolvedAt: string | null;
6333
+ createdAt: string;
6334
+ updatedAt: string;
6335
+ comments: components["schemas"]["Comment"][];
6336
+ };
6337
+ /** @description Opaque, versioned comment anchor: document-level, or a text anchor with a required quote and optional relative positions. */
6338
+ CommentAnchor: {
6339
+ /** @constant */
6340
+ type: "document";
6341
+ /** @constant */
6342
+ v: 1;
6343
+ } | {
6344
+ /** @constant */
6345
+ type: "text";
6346
+ /** @constant */
6347
+ v: 1;
6348
+ relStart?: string;
6349
+ relEnd?: string;
6350
+ quote: string;
6351
+ prefix: string;
6352
+ suffix: string;
6353
+ };
6354
+ /** @description One comment; body is null iff soft-deleted (redacted projection with empty mentions). */
6355
+ Comment: {
6356
+ id: string;
6357
+ threadId: string;
6358
+ authorUserId: string | null;
6359
+ body: string | null;
6360
+ editedAt: string | null;
6361
+ deletedAt: string | null;
6362
+ createdAt: string;
6363
+ mentions: string[];
6364
+ };
6365
+ /** @description A comment thread without its comments. */
6366
+ CommentThreadSummary: {
6367
+ id: string;
6368
+ /** @enum {string} */
6369
+ subjectType: "company_md" | "strategy_doc" | "work_item" | "meeting_recording";
6370
+ subjectId: string;
6371
+ /** @enum {string} */
6372
+ anchorType: "document" | "text";
6373
+ anchor: components["schemas"]["CommentAnchor"];
6374
+ /** @enum {string} */
6375
+ status: "open" | "resolved";
6376
+ createdByUserId: string | null;
6377
+ resolvedByUserId: string | null;
6378
+ resolvedAt: string | null;
6379
+ createdAt: string;
6380
+ updatedAt: string;
6381
+ };
6177
6382
  };
6178
6383
  responses: never;
6179
6384
  parameters: never;
@@ -11905,4 +12110,247 @@ export interface operations {
11905
12110
  };
11906
12111
  };
11907
12112
  };
12113
+ listCommentThreads: {
12114
+ parameters: {
12115
+ query: {
12116
+ subjectType: "company_md" | "strategy_doc" | "work_item" | "meeting_recording";
12117
+ subjectId: string;
12118
+ status?: "open" | "resolved";
12119
+ };
12120
+ header?: never;
12121
+ path?: never;
12122
+ cookie?: never;
12123
+ };
12124
+ requestBody?: never;
12125
+ responses: {
12126
+ /** @description Every thread on the subject with its comments oldest-first */
12127
+ 200: {
12128
+ headers: {
12129
+ [name: string]: unknown;
12130
+ };
12131
+ content: {
12132
+ "application/json": components["schemas"]["CommentThreadListResponse"];
12133
+ };
12134
+ };
12135
+ /** @description Invalid query parameters */
12136
+ 400: {
12137
+ headers: {
12138
+ [name: string]: unknown;
12139
+ };
12140
+ content?: never;
12141
+ };
12142
+ /** @description Subject not found, or the caller may not read it */
12143
+ 404: {
12144
+ headers: {
12145
+ [name: string]: unknown;
12146
+ };
12147
+ content?: never;
12148
+ };
12149
+ };
12150
+ };
12151
+ createCommentThread: {
12152
+ parameters: {
12153
+ query?: never;
12154
+ header?: never;
12155
+ path?: never;
12156
+ cookie?: never;
12157
+ };
12158
+ requestBody: {
12159
+ content: {
12160
+ "application/json": components["schemas"]["CreateCommentThreadRequest"];
12161
+ };
12162
+ };
12163
+ responses: {
12164
+ /** @description Thread created with its opening comment (one transaction) */
12165
+ 201: {
12166
+ headers: {
12167
+ [name: string]: unknown;
12168
+ };
12169
+ content: {
12170
+ "application/json": components["schemas"]["CommentThreadResponse"];
12171
+ };
12172
+ };
12173
+ /** @description Invalid body (including every anchor-corpus reject case) */
12174
+ 400: {
12175
+ headers: {
12176
+ [name: string]: unknown;
12177
+ };
12178
+ content?: never;
12179
+ };
12180
+ /** @description Subject not found, or the caller may not comment on it */
12181
+ 404: {
12182
+ headers: {
12183
+ [name: string]: unknown;
12184
+ };
12185
+ content?: never;
12186
+ };
12187
+ };
12188
+ };
12189
+ replyToCommentThread: {
12190
+ parameters: {
12191
+ query?: never;
12192
+ header?: never;
12193
+ path: {
12194
+ threadId: string;
12195
+ };
12196
+ cookie?: never;
12197
+ };
12198
+ requestBody: {
12199
+ content: {
12200
+ "application/json": components["schemas"]["CommentReplyRequest"];
12201
+ };
12202
+ };
12203
+ responses: {
12204
+ /** @description Reply written; reopened=true when it auto-reopened a resolved thread */
12205
+ 201: {
12206
+ headers: {
12207
+ [name: string]: unknown;
12208
+ };
12209
+ content: {
12210
+ "application/json": components["schemas"]["CommentReplyResponse"];
12211
+ };
12212
+ };
12213
+ /** @description Invalid body */
12214
+ 400: {
12215
+ headers: {
12216
+ [name: string]: unknown;
12217
+ };
12218
+ content?: never;
12219
+ };
12220
+ /** @description Thread not found, or the caller may not comment on its subject */
12221
+ 404: {
12222
+ headers: {
12223
+ [name: string]: unknown;
12224
+ };
12225
+ content?: never;
12226
+ };
12227
+ };
12228
+ };
12229
+ deleteComment: {
12230
+ parameters: {
12231
+ query?: never;
12232
+ header?: never;
12233
+ path: {
12234
+ commentId: string;
12235
+ };
12236
+ cookie?: never;
12237
+ };
12238
+ requestBody?: never;
12239
+ responses: {
12240
+ /** @description Comment soft-deleted; response carries the redacted projection */
12241
+ 200: {
12242
+ headers: {
12243
+ [name: string]: unknown;
12244
+ };
12245
+ content: {
12246
+ "application/json": components["schemas"]["CommentResponse"];
12247
+ };
12248
+ };
12249
+ /** @description Comment not found, already deleted, or the caller may not delete it */
12250
+ 404: {
12251
+ headers: {
12252
+ [name: string]: unknown;
12253
+ };
12254
+ content?: never;
12255
+ };
12256
+ };
12257
+ };
12258
+ editComment: {
12259
+ parameters: {
12260
+ query?: never;
12261
+ header?: never;
12262
+ path: {
12263
+ commentId: string;
12264
+ };
12265
+ cookie?: never;
12266
+ };
12267
+ requestBody: {
12268
+ content: {
12269
+ "application/json": components["schemas"]["EditCommentRequest"];
12270
+ };
12271
+ };
12272
+ responses: {
12273
+ /** @description Comment updated with edited_at stamped */
12274
+ 200: {
12275
+ headers: {
12276
+ [name: string]: unknown;
12277
+ };
12278
+ content: {
12279
+ "application/json": components["schemas"]["CommentResponse"];
12280
+ };
12281
+ };
12282
+ /** @description Invalid body */
12283
+ 400: {
12284
+ headers: {
12285
+ [name: string]: unknown;
12286
+ };
12287
+ content?: never;
12288
+ };
12289
+ /** @description Comment not found, deleted, or the caller is not its author */
12290
+ 404: {
12291
+ headers: {
12292
+ [name: string]: unknown;
12293
+ };
12294
+ content?: never;
12295
+ };
12296
+ };
12297
+ };
12298
+ resolveCommentThread: {
12299
+ parameters: {
12300
+ query?: never;
12301
+ header?: never;
12302
+ path: {
12303
+ threadId: string;
12304
+ };
12305
+ cookie?: never;
12306
+ };
12307
+ requestBody?: never;
12308
+ responses: {
12309
+ /** @description Thread resolved with resolved_at + resolved_by stamped */
12310
+ 200: {
12311
+ headers: {
12312
+ [name: string]: unknown;
12313
+ };
12314
+ content: {
12315
+ "application/json": components["schemas"]["CommentThreadSummaryResponse"];
12316
+ };
12317
+ };
12318
+ /** @description Thread not found, or the caller may not comment on its subject */
12319
+ 404: {
12320
+ headers: {
12321
+ [name: string]: unknown;
12322
+ };
12323
+ content?: never;
12324
+ };
12325
+ };
12326
+ };
12327
+ reopenCommentThread: {
12328
+ parameters: {
12329
+ query?: never;
12330
+ header?: never;
12331
+ path: {
12332
+ threadId: string;
12333
+ };
12334
+ cookie?: never;
12335
+ };
12336
+ requestBody?: never;
12337
+ responses: {
12338
+ /** @description Thread reopened with the resolution stamp cleared */
12339
+ 200: {
12340
+ headers: {
12341
+ [name: string]: unknown;
12342
+ };
12343
+ content: {
12344
+ "application/json": components["schemas"]["CommentThreadSummaryResponse"];
12345
+ };
12346
+ };
12347
+ /** @description Thread not found, or the caller may not comment on its subject */
12348
+ 404: {
12349
+ headers: {
12350
+ [name: string]: unknown;
12351
+ };
12352
+ content?: never;
12353
+ };
12354
+ };
12355
+ };
11908
12356
  }
@@ -21,6 +21,12 @@ export const openApiRoutes = {
21
21
  '/api/chats/{id}/messages': ['POST'],
22
22
  '/api/chats/{id}/messages/{messageId}': ['DELETE'],
23
23
  '/api/chats/{id}/pin': ['DELETE', 'POST'],
24
+ '/api/comments': ['GET'],
25
+ '/api/comments/threads': ['POST'],
26
+ '/api/comments/threads/{threadId}/comments': ['POST'],
27
+ '/api/comments/threads/{threadId}/reopen': ['POST'],
28
+ '/api/comments/threads/{threadId}/resolve': ['POST'],
29
+ '/api/comments/{commentId}': ['DELETE', 'PATCH'],
24
30
  '/api/company-md/access-requests/{id}/approve': ['POST'],
25
31
  '/api/company-md/access-requests/{id}/deny': ['POST'],
26
32
  '/api/company-md/context-bank': ['POST'],
@@ -11,8 +11,6 @@
11
11
  * ./config.ts so the package public surface is unchanged.
12
12
  *
13
13
  * Design principle: Configuration is data, not code.
14
- *
15
- * @architecture-role universal
16
14
  */
17
15
 
18
16
  // =============================================================================
@@ -10,8 +10,6 @@
10
10
  * ./config.ts so the package public surface is unchanged.
11
11
  *
12
12
  * Design principle: Configuration is data, not code.
13
- *
14
- * @architecture-role universal
15
13
  */
16
14
 
17
15
  import type { Soc2ControlArea } from "./types";
@@ -142,6 +142,62 @@ describe("CompanyMdCollabSseEventSchema — raw backend frames", () => {
142
142
  ).not.toThrow();
143
143
  });
144
144
 
145
+ /**
146
+ * `canEdit` is permission and `status` is occupancy — the pair below is the
147
+ * case that motivated the field (ADR-CONTRACTS-114). A read-only participant
148
+ * and an edit-capable one who is merely reading BOTH report `viewing`, so a
149
+ * consumer that split the roster on `status` would get the wrong answer for
150
+ * one of them. These three assertions lock that the schema keeps the two
151
+ * axes independent, including the absent case a rollout produces.
152
+ */
153
+ it("carries canEdit independently of status", () => {
154
+ const base = {
155
+ type: "collab-presence" as const,
156
+ v: 1 as const,
157
+ userId: USER_ID,
158
+ clientKey: CLIENT_KEY,
159
+ };
160
+
161
+ // Edit-capable, but reading. Occupancy says viewing; permission says yes.
162
+ const reader = CompanyMdCollabSseEventSchema.parse({
163
+ ...base,
164
+ status: "viewing",
165
+ canEdit: true,
166
+ });
167
+ expect(reader).toMatchObject({ status: "viewing", canEdit: true });
168
+
169
+ // Read-only, and on the editor tab. Occupancy is the same; permission is not.
170
+ const viewer = CompanyMdCollabSseEventSchema.parse({
171
+ ...base,
172
+ status: "viewing",
173
+ canEdit: false,
174
+ });
175
+ expect(viewer).toMatchObject({ status: "viewing", canEdit: false });
176
+
177
+ // An older server omits the field. It must parse, and it must not
178
+ // materialize as `true` — absent means no edit rights, so a consumer
179
+ // reading `canEdit === true` fails closed on its own.
180
+ const legacy = CompanyMdCollabSseEventSchema.parse({
181
+ ...base,
182
+ status: "editing",
183
+ });
184
+ expect(legacy).toMatchObject({ status: "editing" });
185
+ expect((legacy as { canEdit?: boolean }).canEdit).toBeUndefined();
186
+ });
187
+
188
+ it("rejects a non-boolean canEdit", () => {
189
+ expect(() =>
190
+ CompanyMdCollabSseEventSchema.parse({
191
+ type: "collab-presence",
192
+ v: 1,
193
+ userId: USER_ID,
194
+ clientKey: CLIENT_KEY,
195
+ status: "editing",
196
+ canEdit: "yes",
197
+ }),
198
+ ).toThrow();
199
+ });
200
+
145
201
  it("parses a collab-reset frame for each reason the backend sends", () => {
146
202
  for (const reason of ["epoch-mismatch", "compacted", "cursor-ahead"]) {
147
203
  expect(() =>
@@ -235,7 +235,9 @@ export type CompanyMdCollabUpdateEvent = z.infer<
235
235
  * `userId` is trustworthy: the server stamps it from the posting session and
236
236
  * never takes it from a client body. That is why no client-supplied identity
237
237
  * type is published — receiving clients resolve name/avatar/colour from
238
- * `userId` themselves.
238
+ * `userId` themselves. `canEdit` is stamped from the same session for the same
239
+ * reason: it is an authority answer, and an authority answer a client could
240
+ * assert about itself is not one.
239
241
  */
240
242
  export const CompanyMdCollabPresenceEventSchema = z.object({
241
243
  type: z.literal("collab-presence"),
@@ -247,6 +249,23 @@ export const CompanyMdCollabPresenceEventSchema = z.object({
247
249
  clientKey: z.string().uuid(),
248
250
  /** What that editor instance is doing. */
249
251
  status: z.enum(["editing", "viewing"]),
252
+ /**
253
+ * Whether this participant may WRITE the document — server-stamped from the
254
+ * same `CompanyMd.CanEdit` binding the sync handshake reports as `editable`,
255
+ * never taken from a client body.
256
+ *
257
+ * Distinct from `status`, which is occupancy: `status` says what one editor
258
+ * instance is doing right now, this says what the person is ALLOWED to do.
259
+ * The roster admits viewers on purpose (CanView, not CanEdit), so a reader
260
+ * cannot tell the two apart without this — and a client must never re-derive
261
+ * it from an ACL, which would stand up a second access authority in the
262
+ * browser.
263
+ *
264
+ * Optional so an older server stays representable on the wire. ABSENT MEANS
265
+ * NO EDIT RIGHTS: a consumer reads the missing field as `false` rather than
266
+ * guessing, which keeps the surface default-deny through a rollout.
267
+ */
268
+ canEdit: z.boolean().optional(),
250
269
  /** Selection anchor — an encoded Y.RelativePosition blob. */
251
270
  anchor: z.string().optional(),
252
271
  /** Selection head — an encoded Y.RelativePosition blob. */
@@ -129,8 +129,31 @@ const MemberRecentActionSchema = z.object({
129
129
  summary: z.string(),
130
130
  });
131
131
 
132
+ /**
133
+ * Which bundle or role contributed a resolved scope (ADR-CTRL-360).
134
+ *
135
+ * `member_default` / `admin` / `account_owner` are the static actor-class
136
+ * bundles AUTH-011C re-homed the deleted role scope lists onto (ADR-BE-184);
137
+ * `role:<name>` is a surviving RBAC role assignment. Open string rather than an
138
+ * enum because `rbac_roles` is data, not a closed vocabulary.
139
+ *
140
+ * A scope carries EVERY band that granted it — composition is a union, not a
141
+ * ladder, so an owner who also holds admin authority legitimately shows both.
142
+ * Display/audit only; `effectiveScopes` remains the authorization surface.
143
+ */
144
+ const ScopeProvenanceEntrySchema = z.object({
145
+ scope: z.string(),
146
+ bands: z.array(z.string()).min(1),
147
+ });
148
+
132
149
  export const WorkspaceMemberDetailSchema = WorkspaceMemberSchema.extend({
133
150
  effectiveScopes: z.array(z.string()),
151
+ /**
152
+ * Per-scope provenance for {@link WorkspaceMemberDetailSchema.shape.effectiveScopes}.
153
+ * Optional: absence means the server did not compute it (legacy callsite, or a
154
+ * non-user actor), never that the scopes were granted by nothing.
155
+ */
156
+ scopeProvenance: z.array(ScopeProvenanceEntrySchema).optional(),
134
157
  recentActions: z.array(MemberRecentActionSchema),
135
158
  });
136
159