@company-semantics/contracts 46.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": "46.0.0",
3
+ "version": "47.1.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -86,6 +86,7 @@
86
86
  "build": "tsc -b --noEmit",
87
87
  "typecheck": "NODE_OPTIONS='--max-old-space-size=4096' tsc -b --noEmit",
88
88
  "typecheck:ci": "NODE_OPTIONS='--max-old-space-size=4096' tsc -p scripts/ci/tsconfig.json",
89
+ "lint": "eslint src/ scripts/ tests/ __tests__/ vitest.config.ts",
89
90
  "lint:md": "markdownlint-cli2 '**/*.md' '#node_modules' '#.ralph/worktrees' '#.claude/worktrees'",
90
91
  "format": "prettier --write src/",
91
92
  "format:check": "prettier --check src/",
@@ -137,9 +138,11 @@
137
138
  "zod": "^4.4.3"
138
139
  },
139
140
  "devDependencies": {
141
+ "@eslint/js": "^10.0.1",
140
142
  "@types/node": "^22.20.1",
141
143
  "amphtml-validator": "^1.0.38",
142
144
  "culori": "^4.0.2",
145
+ "eslint": "^10.8.1",
143
146
  "husky": "^9.1.7",
144
147
  "lint-staged": "^17.3.0",
145
148
  "markdownlint-cli2": "^0.23.2",
@@ -147,6 +150,7 @@
147
150
  "prettier": "^3.9.6",
148
151
  "tsx": "^4.23.1",
149
152
  "typescript": "^5.8.3",
153
+ "typescript-eslint": "^8.66.0",
150
154
  "vite": "^8.2.0",
151
155
  "vitest": "^4.1.10",
152
156
  "yaml": "^2.9.0"
@@ -158,6 +162,8 @@
158
162
  "nanoid@<3.3.17": ">=3.3.17",
159
163
  "js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5.0.0",
160
164
  "js-yaml@>=5.0.0 <5.2.2": ">=5.2.2 <6.0.0",
165
+ "ajv@<6.14.0": ">=6.14.0",
166
+ "flatted@<3.4.0": ">=3.4.0",
161
167
  "picomatch@<4.0.4": ">=4.0.4",
162
168
  "esbuild@<0.28.1": ">=0.28.1",
163
169
  "linkify-it@<5.0.2": ">=5.0.2",
@@ -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) {
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '9421bda67789' as const;
3
- export const SPEC_HASH_FULL = '9421bda67789792a5303137aaeb2f7f886cb490e8db9bee11e03f2de13f94806' as const;
2
+ export const SPEC_HASH = '018976cde74a' as const;
3
+ export const SPEC_HASH_FULL = '018976cde74aa4a20c06c5fb333963bf58199fba93d623cd89cf2a360d95eceb' as const;
@@ -4840,6 +4840,7 @@ export interface components {
4840
4840
  canEdit: boolean;
4841
4841
  canUploadContext?: boolean;
4842
4842
  canManageAcl?: boolean;
4843
+ canComment: boolean;
4843
4844
  canChangeVisibility?: boolean;
4844
4845
  inheritsFromId: string | null;
4845
4846
  inheritsFrom: string | null;
@@ -14,7 +14,6 @@ import { buildCapabilityGraph } from "../../../mcp/capability-graph";
14
14
  import type {
15
15
  MCPToolDescriptor,
16
16
  ToolDiscoveryResponse,
17
- CapabilityGraph,
18
17
  } from "../../../mcp/index";
19
18
 
20
19
  /**
@@ -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
@@ -173,6 +173,29 @@ export const CompanyMdDocResponseSchema = z.object({
173
173
  * outside the single-doc read.
174
174
  */
175
175
  canManageAcl: z.boolean().optional(),
176
+ /**
177
+ * CONVERSATION capability signal (ADR-CONTRACTS-118 / ADR-BE-520): whether the
178
+ * requesting actor holds `CompanyMd.CanComment` (the `commenter` band and up)
179
+ * on this doc — the SAME binding `assertCanCommentSubject` enforces on every
180
+ * comment write AND, since ADR-BE-520, `assertCanReadSubject` enforces on the
181
+ * thread read. The app mounts or withholds the ENTIRE comment surface on it —
182
+ * the rail, the toolbar control, the open-thread badge, the preview
183
+ * highlights, the gutter marks and the thread fetch itself — not merely a
184
+ * create affordance: a document's conversation sits inside that document's
185
+ * confidentiality envelope rather than forming an annotation layer over it
186
+ * (ADR-APP-122). Signal only; admittance and enforcement are unchanged.
187
+ *
188
+ * REQUIRED, unlike the three signals above it, and the departure is
189
+ * deliberate. Those are optional for back-compat with doc shapes returned
190
+ * outside the single-doc read; this one gates a confidentiality surface, where
191
+ * an absent value means "unknown" rather than "false" and any client-invented
192
+ * default is a guess. Making it required is what lets the app delete the
193
+ * `canComment ?? canEdit` shadow-field fallback outright instead of carrying a
194
+ * second, weaker answer beside the server's. Pre-launch there is no
195
+ * back-compat consumer to protect, so the field is required and the package
196
+ * takes the major bump.
197
+ */
198
+ canComment: z.boolean(),
176
199
  /**
177
200
  * GOVERNANCE capability signal (ADR-BE-435): whether the requesting actor holds
178
201
  * `CompanyMd.CanChangeVisibility` (owner OR the `org.manage_content_sharing`
@@ -50,7 +50,7 @@ describe("resolveAvatar", () => {
50
50
  });
51
51
 
52
52
  it("returns initials source when no avatarUrl", () => {
53
- const result = resolveAvatar({ fullName: "Ian Heidt" } as any);
53
+ const result = resolveAvatar({ fullName: "Ian Heidt" });
54
54
  expect(result.source).toBe("initials");
55
55
  expect(result.initials).toBe("IH");
56
56
  expect(result.url).toBeUndefined();
@@ -1,26 +1,35 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { WireSurfaceBuilder } from "../wire.js";
3
+ import type { MCPToolDescriptor } from "../../mcp/index.js";
3
4
  import type { ExecutionResultData } from "../execution.js";
4
5
  import type { UndoResultData } from "../execution.js";
5
6
 
7
+ // Deliberately partial fixtures: toolList is a pure wrapper that passes `tools`
8
+ // straight through, so these assert the wrapping, not descriptor validity. Cast
9
+ // through `unknown` rather than `any` — the narrowing is intentional and stated,
10
+ // not an absence of typing.
11
+ const partialTools = (
12
+ tools: ReadonlyArray<Record<string, unknown>>,
13
+ ): MCPToolDescriptor[] => tools as unknown as MCPToolDescriptor[];
14
+
6
15
  describe("WireSurfaceBuilder.toolList", () => {
7
16
  it("returns data part with type data-tool-list", () => {
8
- const tools = [
17
+ const tools = partialTools([
9
18
  { name: "search", description: "Search tool", inputSchema: {} },
10
- ] as any;
19
+ ]);
11
20
  const result = WireSurfaceBuilder.toolList(tools);
12
21
  expect(result.type).toBe("data-tool-list");
13
22
  });
14
23
 
15
24
  it("data.tools matches input array exactly", () => {
16
- const tools = [
25
+ const tools = partialTools([
17
26
  { name: "search", description: "Search tool", inputSchema: {} },
18
27
  {
19
28
  name: "fetch",
20
29
  description: "Fetch tool",
21
30
  inputSchema: { type: "object" },
22
31
  },
23
- ] as any;
32
+ ]);
24
33
  const result = WireSurfaceBuilder.toolList(tools);
25
34
  expect(result.data.tools).toEqual(tools);
26
35
  });
@@ -9,7 +9,11 @@
9
9
  * levels are materialized into `effective_acl_grants`, and ADR-CTRL-086 for
10
10
  * the aggregation rule (most-permissive across sources).
11
11
  *
12
- * The commenter level is currently a stub — see ADR-CTRL-087.
12
+ * The commenter level is NO LONGER a stub. ADR-CTRL-087 held the placeholder
13
+ * open; ADR-BE-512 discharged it by giving the band a real domain (polymorphic
14
+ * comment threads bound to `CompanyMd.CanComment`), and ADR-BE-520 made that
15
+ * binding gate thread reads as well as writes. See also ADR-CONTRACTS-118 for
16
+ * the `canComment` capability signal the doc read publishes.
13
17
  *
14
18
  * `meta` is the metadata-only tier used for org-admin lifecycle access
15
19
  * (ADR-BE-245). It is materialized/derived only — never an explicit
@@ -46,6 +46,10 @@ export const ACCESS_REQUEST_MESSAGE_MAX_LENGTH = 280;
46
46
  * is rendered as text, never as markup, so this is about what may be STORED,
47
47
  * not about escaping at render time.
48
48
  */
49
+ // The control characters ARE the subject of this pattern — it exists to reject
50
+ // them. no-control-regex flags them as probable typos, which is the opposite of
51
+ // the intent documented above.
52
+ // eslint-disable-next-line no-control-regex
49
53
  const PLAIN_TEXT = /^[^\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]*$/;
50
54
 
51
55
  /** POST body to create an access request. */
@@ -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
  }
@@ -41,6 +41,9 @@ export const OrgSecretsActionSchema = z.enum([
41
41
  ]);
42
42
  export type OrgSecretsAction = z.infer<typeof OrgSecretsActionSchema>;
43
43
 
44
+ // The brand exists only to be referenced in a type position below; the value
45
+ // binding is never read, which is the whole point of the nominal-typing idiom.
46
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
44
47
  declare const SecretValueStringBrand: unique symbol;
45
48
 
46
49
  /**