@company-semantics/contracts 47.0.0 → 47.1.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": "47.0.0",
3
+ "version": "47.1.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,6 +60,65 @@ describe("resource-keys: orgUnitOpenRoles (per-unit identity)", () => {
60
60
  });
61
61
  });
62
62
 
63
+ describe("resource-keys: directGrants (org-scoped)", () => {
64
+ const key: ResourceKey = { type: "directGrants", orgId: ORG_ID };
65
+
66
+ it("toQueryKey produces [type, orgId]", () => {
67
+ const qk = toQueryKey(key);
68
+ expect(qk).toEqual(["directGrants", ORG_ID]);
69
+ expect(qk).toHaveLength(2);
70
+ });
71
+
72
+ it("roundtrips through fromQueryKey", () => {
73
+ expect(fromQueryKey(toQueryKey(key))).toEqual(key);
74
+ });
75
+
76
+ it("matchesResourceKey is org-scoped: rejects a different orgId", () => {
77
+ const other = "22222222-2222-4222-8222-222222222222";
78
+ expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
79
+ expect(matchesResourceKey(["directGrants", other], key)).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe("resource-keys: orgUnitMyAuthority (per-unit identity)", () => {
84
+ const UNIT_ID = "33333333-3333-4333-8333-333333333333";
85
+ const key: ResourceKey = {
86
+ type: "orgUnitMyAuthority",
87
+ orgId: ORG_ID,
88
+ unitId: UNIT_ID,
89
+ };
90
+
91
+ it("toQueryKey produces [type, orgId, unitId]", () => {
92
+ const qk = toQueryKey(key);
93
+ expect(qk).toEqual(["orgUnitMyAuthority", ORG_ID, UNIT_ID]);
94
+ expect(qk).toHaveLength(3);
95
+ });
96
+
97
+ it("roundtrips through fromQueryKey", () => {
98
+ expect(fromQueryKey(toQueryKey(key))).toEqual(key);
99
+ });
100
+
101
+ it("matchesResourceKey is unitId-scoped: rejects a different unit", () => {
102
+ const otherUnit = "44444444-4444-4444-8444-444444444444";
103
+ expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
104
+ expect(
105
+ matchesResourceKey(["orgUnitMyAuthority", ORG_ID, otherUnit], key),
106
+ ).toBe(false);
107
+ });
108
+
109
+ it("does not collide with orgUnitPermissions on the same unit", () => {
110
+ const permissions: ResourceKey = {
111
+ type: "orgUnitPermissions",
112
+ orgId: ORG_ID,
113
+ unitId: UNIT_ID,
114
+ };
115
+ // Same subject, different resource: an invalidation of one must not sweep
116
+ // the other, or the split that motivated registering this key is inert.
117
+ expect(matchesResourceKey(toQueryKey(permissions), key)).toBe(false);
118
+ expect(matchesResourceKey(toQueryKey(key), permissions)).toBe(false);
119
+ });
120
+ });
121
+
63
122
  describe("resource-keys: system-scoped internalAdmin* types", () => {
64
123
  const systemTypes = [
65
124
  "internalAdminAiProviders",
@@ -67,6 +126,7 @@ describe("resource-keys: system-scoped internalAdmin* types", () => {
67
126
  "internalAdminAiRuntimeDefaults",
68
127
  "factoryFloor",
69
128
  "factorySnapshot",
129
+ "factoryKpis",
70
130
  ] as const;
71
131
 
72
132
  for (const type of systemTypes) {
@@ -55,8 +55,9 @@ arbitrate — see the first invariant.
55
55
  - **Mention identity renders from the mention row, never from the body
56
56
  characters under the range.** A stale or forged range can only
57
57
  mis-**highlight**; it can never misattribute a mention to the wrong person.
58
- Offsets are nullable (and null on every row today, pending the offset
59
- columns) treat null as "highlight nothing", not as an error.
58
+ Offsets are nullable treat null as "highlight nothing", not as an error.
59
+ Mentions written since ADR-BE-522 carry a real range; older rows carry null
60
+ permanently, since recovering one would mean searching the body for a name.
60
61
  - **These shapes are MIRRORED from the backend route boundary, not authored
61
62
  here.** Every bound matches
62
63
  `company-semantics-backend/src/api/http/routes/comments/comments.schemas.ts`,
@@ -147,8 +147,9 @@ describe("CommentSchema and the redaction invariant", () => {
147
147
 
148
148
  describe("CommentMentionSchema offsets", () => {
149
149
  it("parses a mention whose range is null", () => {
150
- // Null on every row today `comment_mentions` has no offset columns yet.
151
- // A client must render these as "highlight nothing", so they must parse.
150
+ // The state of every mention written before ADR-BE-522, permanently: the
151
+ // ranges were never stored and cannot be recovered. A client must render
152
+ // these as "highlight nothing", so they must parse.
152
153
  expect(
153
154
  CommentMentionSchema.safeParse(
154
155
  makeMention({ startOffset: null, endOffset: null }),
@@ -75,12 +75,13 @@ export type CommentSubjectType = z.infer<typeof CommentSubjectTypeSchema>;
75
75
  * RANGE. A stale or forged range can therefore only mis-HIGHLIGHT; it can never
76
76
  * misattribute a mention to the wrong person.
77
77
  *
78
- * `startOffset`/`endOffset` are NULLABLE, and null on every row today:
79
- * `comment_mentions` has no offset columns yet, so the ranges the server
80
- * validates in memory have nowhere to persist. The fields are carried in the
81
- * TARGET shape so a client is written against the final contract once treat
82
- * null as "this mention is in the body somewhere, highlight nothing", not as an
83
- * error.
78
+ * `startOffset`/`endOffset` are NULLABLE, and null means "this mention is in
79
+ * the body somewhere, highlight nothing" NEVER an error. Every mention
80
+ * written since ADR-BE-522 carries a real range; rows written before it have
81
+ * none and never will, because the only way to invent one is to search the body
82
+ * for a display name, which is the body-as-identity inversion this contract
83
+ * exists to prevent. A client therefore has to render both, and the two coexist
84
+ * with no version check on either side.
84
85
  *
85
86
  * `displayName` is resolved AT READ TIME from the current user record and is
86
87
  * never stored on the mention row: a rename re-renders with no backfill, and an
@@ -58,6 +58,9 @@ export type ResourceKey =
58
58
  | { type: "deletionEligibility"; orgId: string }
59
59
  | { type: "transferOwnership"; orgId: string }
60
60
  | { type: "companyMdDocs"; orgId: string }
61
+ // Directly-granted (non-inherited) ACL rows for the org, read by the admin
62
+ // grants surface (ADR-CONTRACTS-119).
63
+ | { type: "directGrants"; orgId: string }
61
64
  // OrgUnit canonical model (ADR-BE-120) — Phase 2 Wave 4
62
65
  | { type: "orgTree"; orgId: string }
63
66
  | { type: "orgLevelConfig"; orgId: string }
@@ -74,6 +77,12 @@ export type ResourceKey =
74
77
  // Org-unit owners list (ADR-CONTRACTS-052) — owners are an org-wide
75
78
  // projection, not a per-unit collection, so unitId is intentionally excluded.
76
79
  | { type: "orgUnitOwners"; orgId: string }
80
+ // THIS VIEWER'S authority over one unit (ADR-CONTRACTS-119) — what the viewer
81
+ // may do here, not what the unit's permission rows say. Deliberately distinct
82
+ // from `orgUnitPermissions`: same subject, different resource and different
83
+ // invalidation trigger (a membership change alters the viewer's authority
84
+ // without touching the unit's rows), the `actionItems`/`feed` split above.
85
+ | { type: "orgUnitMyAuthority"; orgId: string; unitId: string }
77
86
  // People reporting (ADR-BE-166) — drives the settings Org chart drill-down
78
87
  | { type: "peopleOrgChart"; orgId: string }
79
88
  // System-scoped (ADR-CONTRACTS-052) — tenant-less super-admin resources.
@@ -85,6 +94,11 @@ export type ResourceKey =
85
94
  // dashboard reads over Global-infra factory tables; tenant-less, scope 'system'.
86
95
  | { type: "factoryFloor"; scope: "system" }
87
96
  | { type: "factorySnapshot"; scope: "system" }
97
+ // The KPI rollup over the same factory tables (ADR-CONTRACTS-119). Registered
98
+ // late: the app shipped it as a double-cast key literal, and because
99
+ // `useResource` disables a query whose `toQueryKey` throws, the panel it backs
100
+ // never fetched at all. An unregistered key is not a stale read, it is no read.
101
+ | { type: "factoryKpis"; scope: "system" }
88
102
  // Everything awaiting the viewer's decision, across every domain
89
103
  // (ADR-CONT-104). ORG-scoped, not user-scoped, even though the content is
90
104
  // per-viewer: the bucket differs per org, so a user-keyed entry would serve
@@ -128,7 +142,101 @@ export function resolveScope(context: {
128
142
  return { orgId: context.impersonatedOrgId ?? context.orgId };
129
143
  }
130
144
 
131
- /** All ResourceKey type literals for exhaustive checking. */
145
+ /*
146
+ * ---------------------------------------------------------------------------
147
+ * fromQueryKey's routing tables, and the drift detector over them.
148
+ * ---------------------------------------------------------------------------
149
+ *
150
+ * `toQueryKey` has always been safe: its switch is exhaustive and its `never`
151
+ * guard makes a new union member a compile error. `fromQueryKey` was not. Its
152
+ * five lookup tables are hand-maintained, and a member missing from all of them
153
+ * fell through to a RUNTIME `throw` — which `matchesResourceKey` catches and
154
+ * turns into "matches nothing", and which `useResource` turns into a query that
155
+ * never fetches. Silent in both directions (ADR-CONTRACTS-119).
156
+ *
157
+ * Two mechanisms, and BOTH are needed:
158
+ *
159
+ * `satisfies` on each table catches a WRONG entry — a typo, or a literal
160
+ * parked under the wrong scope. It cannot catch an entry that is simply
161
+ * absent, because an array is free to be a subset of its element type.
162
+ *
163
+ * `_EveryResourceKeyIsRouted` below catches the ABSENT entry, which is the
164
+ * failure that actually shipped. It is the half that closes the hole.
165
+ */
166
+
167
+ /**
168
+ * The identity segments a member carries — every field that is neither the type
169
+ * tag nor a scope discriminator. `member` → `"memberId"`; `commentThreads` →
170
+ * `"subjectType" | "subjectId"`; a scope-only key → `never`.
171
+ */
172
+ type IdentityFieldsOf<T extends ResourceKey["type"]> = Exclude<
173
+ keyof Extract<ResourceKey, { type: T }>,
174
+ "type" | "orgId" | "userId" | "scope"
175
+ >;
176
+
177
+ /**
178
+ * Type literals whose whole shape is the tag plus one scope discriminator —
179
+ * i.e. the members that belong in a scope ARRAY rather than an identity MAP.
180
+ * Distributes over the union (`K` is a naked type parameter).
181
+ */
182
+ type ScopeOnlyType<K = ResourceKey> = K extends {
183
+ type: infer T extends ResourceKey["type"];
184
+ }
185
+ ? [IdentityFieldsOf<T>] extends [never]
186
+ ? T
187
+ : never
188
+ : never;
189
+
190
+ type OrgScopedType = Extract<
191
+ ScopeOnlyType,
192
+ Extract<ResourceKey, { orgId: string }>["type"]
193
+ >;
194
+ type UserScopedType = Extract<
195
+ ScopeOnlyType,
196
+ Extract<ResourceKey, { userId: string }>["type"]
197
+ >;
198
+ type SystemScopedType = Extract<
199
+ ScopeOnlyType,
200
+ Extract<ResourceKey, { scope: "system" }>["type"]
201
+ >;
202
+
203
+ /**
204
+ * Identities with a single extra segment. The `satisfies` checks the FIELD NAME
205
+ * against that member's own keys, so renaming `memberId` in the union — or
206
+ * pointing a key at a field it does not have — fails to compile here.
207
+ */
208
+ const IDENTITY_FIELDS = {
209
+ member: "memberId",
210
+ team: "teamId",
211
+ department: "departmentId",
212
+ chat: "chatId",
213
+ companyMdDoc: "slug",
214
+ companyMdContextBank: "slug",
215
+ companyMdAccessRequests: "docId",
216
+ orgUnit: "unitId",
217
+ orgUnitChildren: "unitId",
218
+ orgUnitAncestors: "unitId",
219
+ orgUnitMemberships: "unitId",
220
+ orgUnitPermissions: "unitId",
221
+ orgUnitOpenRoles: "unitId",
222
+ orgUnitMyAuthority: "unitId",
223
+ } as const satisfies { [T in ResourceKey["type"]]?: IdentityFieldsOf<T> };
224
+
225
+ /**
226
+ * Identities with a COMPOSITE (multi-segment) identity. Checked before
227
+ * {@link IDENTITY_FIELDS}, which hard-asserts a two-element `rest` and would
228
+ * otherwise reject these.
229
+ *
230
+ * A map of its own rather than widening `IDENTITY_FIELDS` to `string | string[]`:
231
+ * the single-segment case is every other key in the union, and making it pay for
232
+ * this one would put a branch in the hot path for no reader's benefit.
233
+ */
234
+ const COMPOSITE_IDENTITY_FIELDS = {
235
+ commentThreads: ["subjectType", "subjectId"],
236
+ } as const satisfies {
237
+ [T in ResourceKey["type"]]?: readonly IdentityFieldsOf<T>[];
238
+ };
239
+
132
240
  const ORG_SCOPED_TYPES = [
133
241
  "members",
134
242
  "departments",
@@ -146,13 +254,14 @@ const ORG_SCOPED_TYPES = [
146
254
  "deletionEligibility",
147
255
  "transferOwnership",
148
256
  "companyMdDocs",
257
+ "directGrants",
149
258
  "orgTree",
150
259
  "orgLevelConfig",
151
260
  "peopleOrgChart",
152
261
  "orgUnitOwners",
153
262
  "actionItems",
154
263
  "feed",
155
- ] as const;
264
+ ] as const satisfies readonly OrgScopedType[];
156
265
 
157
266
  const USER_SCOPED_TYPES = [
158
267
  "dismissedBanners",
@@ -160,7 +269,7 @@ const USER_SCOPED_TYPES = [
160
269
  "sessions",
161
270
  "userMd",
162
271
  "viewer",
163
- ] as const;
272
+ ] as const satisfies readonly UserScopedType[];
164
273
 
165
274
  /**
166
275
  * System-scoped types (ADR-CONTRACTS-052) — tenant-less super-admin resources.
@@ -173,7 +282,34 @@ const SYSTEM_SCOPED_TYPES = [
173
282
  "internalAdminAiRuntimeDefaults",
174
283
  "factoryFloor",
175
284
  "factorySnapshot",
176
- ] as const;
285
+ "factoryKpis",
286
+ ] as const satisfies readonly SystemScopedType[];
287
+
288
+ /** Every literal `fromQueryKey` can route, across all five tables. */
289
+ type RoutedType =
290
+ | keyof typeof IDENTITY_FIELDS
291
+ | keyof typeof COMPOSITE_IDENTITY_FIELDS
292
+ | (typeof ORG_SCOPED_TYPES)[number]
293
+ | (typeof USER_SCOPED_TYPES)[number]
294
+ | (typeof SYSTEM_SCOPED_TYPES)[number];
295
+
296
+ type Assert<T extends true> = T;
297
+
298
+ /**
299
+ * THE DRIFT DETECTOR. Register a union member and forget its routing table and
300
+ * this fails to compile — instead of shipping a key that parses nowhere,
301
+ * matches nothing, and disables the query that reads it.
302
+ *
303
+ * The false branch resolves to the UNROUTED LITERALS rather than to `false`, so
304
+ * the diagnostic names the culprit:
305
+ * `Type '"directGrants"' does not satisfy the constraint 'true'`.
306
+ * Deliberately unexported: this is an assertion, not vocabulary.
307
+ */
308
+ type _EveryResourceKeyIsRouted = Assert<
309
+ [Exclude<ResourceKey["type"], RoutedType>] extends [never]
310
+ ? true
311
+ : Exclude<ResourceKey["type"], RoutedType>
312
+ >;
177
313
 
178
314
  /**
179
315
  * Canonical ResourceKey → query key conversion.
@@ -214,6 +350,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
214
350
  case "orgUnitMemberships":
215
351
  case "orgUnitPermissions":
216
352
  case "orgUnitOpenRoles":
353
+ case "orgUnitMyAuthority":
217
354
  return [key.type, key.orgId, key.unitId] as const;
218
355
 
219
356
  // User-scoped
@@ -241,6 +378,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
241
378
  case "deletionEligibility":
242
379
  case "transferOwnership":
243
380
  case "companyMdDocs":
381
+ case "directGrants":
244
382
  case "orgTree":
245
383
  case "orgLevelConfig":
246
384
  case "peopleOrgChart":
@@ -255,6 +393,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
255
393
  case "internalAdminAiRuntimeDefaults":
256
394
  case "factoryFloor":
257
395
  case "factorySnapshot":
396
+ case "factoryKpis":
258
397
  return [key.type, key.scope] as const;
259
398
 
260
399
  default: {
@@ -277,19 +416,9 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
277
416
  );
278
417
  }
279
418
 
280
- // Identities with a COMPOSITE (multi-segment) identity. Checked before the
281
- // single-field map below, which hard-asserts a two-element `rest` and would
282
- // otherwise reject these.
283
- //
284
- // A map of its own rather than a widening of `identityFields` to a
285
- // `string | string[]`: the single-segment case is every other key in the
286
- // union, and making it pay for this one would put a branch in the hot path
287
- // for no reader's benefit.
288
- const compositeIdentityFields: Record<string, readonly string[]> = {
289
- commentThreads: ["subjectType", "subjectId"],
290
- };
291
-
292
- const compositeFields = compositeIdentityFields[type];
419
+ const compositeFields = (
420
+ COMPOSITE_IDENTITY_FIELDS as Record<string, readonly string[] | undefined>
421
+ )[type];
293
422
  if (compositeFields) {
294
423
  // +1 for the orgId that leads every composite key.
295
424
  if (rest.length !== compositeFields.length + 1) {
@@ -304,33 +433,21 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
304
433
  return key as ResourceKey;
305
434
  }
306
435
 
307
- // Identities with extra field
308
- const identityFields: Record<string, string> = {
309
- member: "memberId",
310
- team: "teamId",
311
- department: "departmentId",
312
- chat: "chatId",
313
- companyMdDoc: "slug",
314
- companyMdContextBank: "slug",
315
- companyMdAccessRequests: "docId",
316
- orgUnit: "unitId",
317
- orgUnitChildren: "unitId",
318
- orgUnitAncestors: "unitId",
319
- orgUnitMemberships: "unitId",
320
- orgUnitPermissions: "unitId",
321
- orgUnitOpenRoles: "unitId",
322
- };
323
-
324
- if (type in identityFields) {
436
+ // Identities with a single extra segment.
437
+ const identityField = (IDENTITY_FIELDS as Record<string, string | undefined>)[
438
+ type
439
+ ];
440
+
441
+ if (identityField !== undefined) {
325
442
  if (rest.length !== 2) {
326
443
  throw new Error(
327
- `Invalid query key for '${type}': expected [type, orgId, ${identityFields[type]}], got ${JSON.stringify(queryKey)}`,
444
+ `Invalid query key for '${type}': expected [type, orgId, ${identityField}], got ${JSON.stringify(queryKey)}`,
328
445
  );
329
446
  }
330
447
  return {
331
448
  type,
332
449
  orgId: rest[0],
333
- [identityFields[type]]: rest[1],
450
+ [identityField]: rest[1],
334
451
  } as ResourceKey;
335
452
  }
336
453
 
@@ -374,76 +491,26 @@ export function matchesResourceKey(
374
491
 
375
492
  if (parsed.type !== targetKey.type) return false;
376
493
 
377
- if (
378
- "orgId" in parsed &&
379
- "orgId" in targetKey &&
380
- parsed.orgId !== targetKey.orgId
381
- )
382
- return false;
383
- if (
384
- "userId" in parsed &&
385
- "userId" in targetKey &&
386
- parsed.userId !== targetKey.userId
387
- )
388
- return false;
389
- if (
390
- "memberId" in parsed &&
391
- "memberId" in targetKey &&
392
- parsed.memberId !== targetKey.memberId
393
- )
394
- return false;
395
- if (
396
- "teamId" in parsed &&
397
- "teamId" in targetKey &&
398
- parsed.teamId !== targetKey.teamId
399
- )
400
- return false;
401
- if (
402
- "departmentId" in parsed &&
403
- "departmentId" in targetKey &&
404
- parsed.departmentId !== targetKey.departmentId
405
- )
406
- return false;
407
- if (
408
- "chatId" in parsed &&
409
- "chatId" in targetKey &&
410
- parsed.chatId !== targetKey.chatId
411
- )
412
- return false;
413
- if ("slug" in parsed && "slug" in targetKey && parsed.slug !== targetKey.slug)
414
- return false;
415
- if (
416
- "docId" in parsed &&
417
- "docId" in targetKey &&
418
- parsed.docId !== targetKey.docId
419
- )
420
- return false;
421
- if (
422
- "unitId" in parsed &&
423
- "unitId" in targetKey &&
424
- parsed.unitId !== targetKey.unitId
425
- )
426
- return false;
427
- // Both halves of a composite identity, or a comment thread list on one
428
- // subject would be invalidated by every other subject in the org.
429
- if (
430
- "subjectType" in parsed &&
431
- "subjectType" in targetKey &&
432
- parsed.subjectType !== targetKey.subjectType
433
- )
434
- return false;
435
- if (
436
- "subjectId" in parsed &&
437
- "subjectId" in targetKey &&
438
- parsed.subjectId !== targetKey.subjectId
439
- )
440
- return false;
441
- if (
442
- "scope" in parsed &&
443
- "scope" in targetKey &&
444
- parsed.scope !== targetKey.scope
445
- )
446
- return false;
494
+ // Field-agnostic on purpose. This was an eleven-branch ladder naming every
495
+ // scope and identity field by hand — a second, silent mirror of the union's
496
+ // shape. A member whose identity segment the ladder forgot still matched, so
497
+ // one subject's invalidation flushed every other subject's cache; that is the
498
+ // ADR-CONTRACTS-116 `subjectType` defect, and enumerating fields is what made
499
+ // it possible. Comparing whatever the target actually carries covers a new
500
+ // identity field by construction (ADR-CONTRACTS-119).
501
+ //
502
+ // Same-type comparison only — `parsed.type === targetKey.type` above — so the
503
+ // two sides are the same union member and a shared field name means the same
504
+ // thing on both. A field present on one side and absent on the other still
505
+ // matches, which is what lets a slug-less `{type:'companyMdDocs', orgId}`
506
+ // sweep its whole type org-wide, exactly as before.
507
+ const target = targetKey as Record<string, unknown>;
508
+ const candidate = parsed as Record<string, unknown>;
509
+ for (const field of Object.keys(target)) {
510
+ if (field === "type") continue;
511
+ const other = candidate[field];
512
+ if (other !== undefined && other !== target[field]) return false;
513
+ }
447
514
 
448
515
  return true;
449
516
  }