@company-semantics/contracts 47.1.1 → 47.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": "47.1.1",
3
+ "version": "47.2.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -148,10 +148,10 @@
148
148
  "markdownlint-cli2": "^0.23.2",
149
149
  "openapi-typescript": "^7.13.0",
150
150
  "prettier": "^3.9.6",
151
- "tsx": "^4.23.1",
151
+ "tsx": "^4.23.11",
152
152
  "typescript": "^5.8.3",
153
153
  "typescript-eslint": "^8.66.0",
154
- "vite": "^8.2.0",
154
+ "vite": "^8.2.1",
155
155
  "vitest": "^4.1.10",
156
156
  "yaml": "^2.9.0"
157
157
  },
@@ -234,6 +234,110 @@ describe("resource-keys: companyMdAccessRequests (per-doc identity)", () => {
234
234
  });
235
235
  });
236
236
 
237
+ describe("resource-keys: companyMdDocVersions (per-doc version list)", () => {
238
+ const DOC_ID = "44444444-4444-4444-8444-444444444444";
239
+ const key: ResourceKey = {
240
+ type: "companyMdDocVersions",
241
+ orgId: ORG_ID,
242
+ docId: DOC_ID,
243
+ };
244
+
245
+ it("serialises to [type, orgId, docId]", () => {
246
+ expect(toQueryKey(key)).toEqual(["companyMdDocVersions", ORG_ID, DOC_ID]);
247
+ });
248
+
249
+ it("round-trips through fromQueryKey", () => {
250
+ expect(fromQueryKey(toQueryKey(key))).toEqual(key);
251
+ });
252
+
253
+ it("discriminates two keys differing only in docId", () => {
254
+ const other: ResourceKey = {
255
+ ...key,
256
+ docId: "55555555-5555-4555-8555-555555555555",
257
+ };
258
+ expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
259
+ expect(matchesResourceKey(toQueryKey(other), key)).toBe(false);
260
+ });
261
+
262
+ it("does NOT collide with companyMdAccessRequests, which shares its shape", () => {
263
+ // Same segments, same field name, same document — only the type tag tells
264
+ // the two apart. An owner's access-request inbox and a version list must
265
+ // never invalidate each other.
266
+ const requestsKey: ResourceKey = {
267
+ type: "companyMdAccessRequests",
268
+ orgId: ORG_ID,
269
+ docId: DOC_ID,
270
+ };
271
+ expect(matchesResourceKey(toQueryKey(requestsKey), key)).toBe(false);
272
+ expect(matchesResourceKey(toQueryKey(key), requestsKey)).toBe(false);
273
+ });
274
+ });
275
+
276
+ describe("resource-keys: companyMdDocVersion (composite version identity)", () => {
277
+ // The second composite member. Its identity is (docId, versionId), so it
278
+ // exercises the same four-segment path `commentThreads` opened — and, unlike
279
+ // that key, it has a SIBLING of the same doc scope one segment shorter
280
+ // (companyMdDocVersions), which is the collision the split exists to avoid.
281
+ const DOC_ID = "44444444-4444-4444-8444-444444444444";
282
+ const VERSION_ID = "66666666-6666-4666-8666-666666666666";
283
+ const key: ResourceKey = {
284
+ type: "companyMdDocVersion",
285
+ orgId: ORG_ID,
286
+ docId: DOC_ID,
287
+ versionId: VERSION_ID,
288
+ };
289
+
290
+ it("serialises to [type, orgId, docId, versionId]", () => {
291
+ const qk = toQueryKey(key);
292
+ expect(qk).toEqual(["companyMdDocVersion", ORG_ID, DOC_ID, VERSION_ID]);
293
+ expect(qk).toHaveLength(4);
294
+ });
295
+
296
+ it("round-trips through fromQueryKey", () => {
297
+ expect(fromQueryKey(toQueryKey(key))).toEqual(key);
298
+ });
299
+
300
+ it("is recognised by isResourceKeyShape", () => {
301
+ expect(isResourceKeyShape(key)).toBe(true);
302
+ });
303
+
304
+ it("discriminates two versions of the same document", () => {
305
+ const other: ResourceKey = {
306
+ ...key,
307
+ versionId: "77777777-7777-4777-8777-777777777777",
308
+ };
309
+ expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
310
+ expect(matchesResourceKey(toQueryKey(other), key)).toBe(false);
311
+ });
312
+
313
+ it("discriminates the same versionId under two different documents", () => {
314
+ const other: ResourceKey = {
315
+ ...key,
316
+ docId: "55555555-5555-4555-8555-555555555555",
317
+ };
318
+ expect(matchesResourceKey(toQueryKey(other), key)).toBe(false);
319
+ expect(matchesResourceKey(toQueryKey(key), other)).toBe(false);
320
+ });
321
+
322
+ it("is NOT invalidated by a sweep of the document's version LIST", () => {
323
+ // The whole reason the two members are distinct. A restore appends to the
324
+ // history and so invalidates `companyMdDocVersions`; the sealed bodies
325
+ // already fetched are immutable and must survive it.
326
+ const listKey: ResourceKey = {
327
+ type: "companyMdDocVersions",
328
+ orgId: ORG_ID,
329
+ docId: DOC_ID,
330
+ };
331
+ expect(matchesResourceKey(toQueryKey(key), listKey)).toBe(false);
332
+ });
333
+
334
+ it("refuses a query key that is missing the version segment", () => {
335
+ expect(() => fromQueryKey(["companyMdDocVersion", ORG_ID, DOC_ID])).toThrow(
336
+ /companyMdDocVersion/,
337
+ );
338
+ });
339
+ });
340
+
237
341
  describe("resource-keys: commentThreads (composite subject identity)", () => {
238
342
  // The FIRST member of the union whose identity needs two segments. Every
239
343
  // other key is [type, scope] or [type, scope, oneId], and `fromQueryKey`'s
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '018976cde74a' as const;
3
- export const SPEC_HASH_FULL = '018976cde74aa4a20c06c5fb333963bf58199fba93d623cd89cf2a360d95eceb' as const;
2
+ export const SPEC_HASH = '6aca68a12d82' as const;
3
+ export const SPEC_HASH_FULL = '6aca68a12d828a1efb9e1d1865a3955e8540d9c83563d2d6be6b04ae3eac7da1' as const;
@@ -1548,6 +1548,57 @@ export interface paths {
1548
1548
  patch?: never;
1549
1549
  trace?: never;
1550
1550
  };
1551
+ "/api/company-md/docs/{id}/versions": {
1552
+ parameters: {
1553
+ query?: never;
1554
+ header?: never;
1555
+ path?: never;
1556
+ cookie?: never;
1557
+ };
1558
+ /** List a company.md document’s version history (metadata only) */
1559
+ get: operations["listCompanyMdDocVersions"];
1560
+ put?: never;
1561
+ post?: never;
1562
+ delete?: never;
1563
+ options?: never;
1564
+ head?: never;
1565
+ patch?: never;
1566
+ trace?: never;
1567
+ };
1568
+ "/api/company-md/docs/{id}/versions/{versionId}": {
1569
+ parameters: {
1570
+ query?: never;
1571
+ header?: never;
1572
+ path?: never;
1573
+ cookie?: never;
1574
+ };
1575
+ /** Read one sealed company.md version, body included */
1576
+ get: operations["getCompanyMdDocVersion"];
1577
+ put?: never;
1578
+ post?: never;
1579
+ delete?: never;
1580
+ options?: never;
1581
+ head?: never;
1582
+ patch?: never;
1583
+ trace?: never;
1584
+ };
1585
+ "/api/company-md/docs/{id}/versions/{versionId}/restore": {
1586
+ parameters: {
1587
+ query?: never;
1588
+ header?: never;
1589
+ path?: never;
1590
+ cookie?: never;
1591
+ };
1592
+ get?: never;
1593
+ put?: never;
1594
+ /** Restore a company.md document to a previous version */
1595
+ post: operations["restoreCompanyMdDocVersion"];
1596
+ delete?: never;
1597
+ options?: never;
1598
+ head?: never;
1599
+ patch?: never;
1600
+ trace?: never;
1601
+ };
1551
1602
  "/api/company-md/extract": {
1552
1603
  parameters: {
1553
1604
  query?: never;
@@ -4915,6 +4966,51 @@ export interface components {
4915
4966
  CompanyMdMentionableResponse: {
4916
4967
  items: components["schemas"]["CompanyMdMentionableCandidate"][];
4917
4968
  };
4969
+ /** @description A page of a company.md document’s version history, plus the opaque token a restore is gated on. */
4970
+ CompanyMdDocVersionsResponse: {
4971
+ currentRevision: string;
4972
+ entries: components["schemas"]["CompanyMdDocVersionEntry"][];
4973
+ nextCursor: string | null;
4974
+ };
4975
+ /** @description One sealed company.md version — its metadata and the body it sealed. */
4976
+ CompanyMdDocVersionContent: {
4977
+ id: string | null;
4978
+ /** @enum {string} */
4979
+ status: "sealed" | "open";
4980
+ /** @enum {string} */
4981
+ mutationSource: "human_edit" | "agent_edit" | "ingest" | "restore" | "system" | "baseline";
4982
+ sourceOperationId: string | null;
4983
+ actor: components["schemas"]["CompanyMdDocVersionActor"] | null;
4984
+ contributors: string[];
4985
+ initiatedBy: string | null;
4986
+ sessionStartedAt: string;
4987
+ sessionEndedAt: string;
4988
+ contentBytes: number;
4989
+ restoredFromVersionId: string | null;
4990
+ isCurrent: boolean;
4991
+ content: string;
4992
+ };
4993
+ /** @description One sealed version, or the in-progress session, as metadata only. */
4994
+ CompanyMdDocVersionEntry: {
4995
+ id: string | null;
4996
+ /** @enum {string} */
4997
+ status: "sealed" | "open";
4998
+ /** @enum {string} */
4999
+ mutationSource: "human_edit" | "agent_edit" | "ingest" | "restore" | "system" | "baseline";
5000
+ sourceOperationId: string | null;
5001
+ actor: components["schemas"]["CompanyMdDocVersionActor"] | null;
5002
+ contributors: string[];
5003
+ initiatedBy: string | null;
5004
+ sessionStartedAt: string;
5005
+ sessionEndedAt: string;
5006
+ contentBytes: number;
5007
+ restoredFromVersionId: string | null;
5008
+ isCurrent: boolean;
5009
+ };
5010
+ /** @description The document revision the client believes it is restoring over. A mismatch is a 409. */
5011
+ RestoreCompanyMdDocVersionRequest: {
5012
+ expectedRevision: string;
5013
+ };
4918
5014
  CompanyMdContextBankResponse: {
4919
5015
  items: {
4920
5016
  id: string;
@@ -6351,6 +6447,12 @@ export interface components {
6351
6447
  displayName: string;
6352
6448
  avatarUrl: string | null;
6353
6449
  };
6450
+ /** @description The singular actor a version is attributed to, in product vocabulary. */
6451
+ CompanyMdDocVersionActor: {
6452
+ /** @enum {string} */
6453
+ type: "member" | "agent" | "system";
6454
+ userId: string;
6455
+ };
6354
6456
  /** @description An ACL-admitted context-doc discovery hit. */
6355
6457
  ContextDocDiscoveryHit: {
6356
6458
  id: string;
@@ -8868,6 +8970,123 @@ export interface operations {
8868
8970
  };
8869
8971
  };
8870
8972
  };
8973
+ listCompanyMdDocVersions: {
8974
+ parameters: {
8975
+ query?: {
8976
+ cursor?: string;
8977
+ limit?: number;
8978
+ };
8979
+ header?: never;
8980
+ path: {
8981
+ id: string;
8982
+ };
8983
+ cookie?: never;
8984
+ };
8985
+ requestBody?: never;
8986
+ responses: {
8987
+ /** @description A page of version metadata, plus the document’s live revision token */
8988
+ 200: {
8989
+ headers: {
8990
+ [name: string]: unknown;
8991
+ };
8992
+ content: {
8993
+ "application/json": components["schemas"]["CompanyMdDocVersionsResponse"];
8994
+ };
8995
+ };
8996
+ /** @description Invalid cursor or limit */
8997
+ 400: {
8998
+ headers: {
8999
+ [name: string]: unknown;
9000
+ };
9001
+ content?: never;
9002
+ };
9003
+ /** @description Document not found, or the caller may not read its body */
9004
+ 404: {
9005
+ headers: {
9006
+ [name: string]: unknown;
9007
+ };
9008
+ content?: never;
9009
+ };
9010
+ };
9011
+ };
9012
+ getCompanyMdDocVersion: {
9013
+ parameters: {
9014
+ query?: never;
9015
+ header?: never;
9016
+ path: {
9017
+ id: string;
9018
+ versionId: string;
9019
+ };
9020
+ cookie?: never;
9021
+ };
9022
+ requestBody?: never;
9023
+ responses: {
9024
+ /** @description The version’s metadata and the body it sealed */
9025
+ 200: {
9026
+ headers: {
9027
+ [name: string]: unknown;
9028
+ };
9029
+ content: {
9030
+ "application/json": components["schemas"]["CompanyMdDocVersionContent"];
9031
+ };
9032
+ };
9033
+ /** @description Document or version not found, or its body is withheld */
9034
+ 404: {
9035
+ headers: {
9036
+ [name: string]: unknown;
9037
+ };
9038
+ content?: never;
9039
+ };
9040
+ };
9041
+ };
9042
+ restoreCompanyMdDocVersion: {
9043
+ parameters: {
9044
+ query?: never;
9045
+ header?: never;
9046
+ path: {
9047
+ id: string;
9048
+ versionId: string;
9049
+ };
9050
+ cookie?: never;
9051
+ };
9052
+ requestBody: {
9053
+ content: {
9054
+ "application/json": components["schemas"]["RestoreCompanyMdDocVersionRequest"];
9055
+ };
9056
+ };
9057
+ responses: {
9058
+ /** @description The restore version that was appended to the document’s history */
9059
+ 200: {
9060
+ headers: {
9061
+ [name: string]: unknown;
9062
+ };
9063
+ content: {
9064
+ "application/json": components["schemas"]["CompanyMdDocVersionEntry"];
9065
+ };
9066
+ };
9067
+ /** @description Missing or malformed expectedRevision */
9068
+ 400: {
9069
+ headers: {
9070
+ [name: string]: unknown;
9071
+ };
9072
+ content?: never;
9073
+ };
9074
+ /** @description Document or version not found, or the caller may not edit it */
9075
+ 404: {
9076
+ headers: {
9077
+ [name: string]: unknown;
9078
+ };
9079
+ content?: never;
9080
+ };
9081
+ /** @description The document changed since the caller read its revision */
9082
+ 409: {
9083
+ headers: {
9084
+ [name: string]: unknown;
9085
+ };
9086
+ content?: never;
9087
+ };
9088
+ };
9089
+ };
8871
9090
  extractCompanyMd: {
8872
9091
  parameters: {
8873
9092
  query?: never;
@@ -53,6 +53,9 @@ export const openApiRoutes = {
53
53
  '/api/company-md/docs/{id}/sharing/policy': ['PUT'],
54
54
  '/api/company-md/docs/{id}/title': ['PUT'],
55
55
  '/api/company-md/docs/{id}/transfer-owner': ['POST'],
56
+ '/api/company-md/docs/{id}/versions': ['GET'],
57
+ '/api/company-md/docs/{id}/versions/{versionId}': ['GET'],
58
+ '/api/company-md/docs/{id}/versions/{versionId}/restore': ['POST'],
56
59
  '/api/company-md/extract': ['POST'],
57
60
  '/api/company-md/ownership/reassign': ['POST'],
58
61
  '/api/company-md/ownership/reassign/suggestion': ['GET'],
@@ -0,0 +1,157 @@
1
+ /**
2
+ * ResourceKey — the shared resource VOCABULARY: one union member per resource
3
+ * the system can name, address, and invalidate.
4
+ *
5
+ * Split out of resource-keys.ts, which owns the OPERATIONS over these keys
6
+ * (scope resolution, the query-key round trip, matching). The union is a
7
+ * registry that grows one member at a time and carries a paragraph of rationale
8
+ * per non-obvious member; the operations are a fixed set of functions. They
9
+ * change for entirely different reasons, and only the union is appended to on
10
+ * essentially every resource addition.
11
+ *
12
+ * All keys MUST include scope (orgId or userId) to prevent cross-org cache leaks.
13
+ * Naming: camelCase nouns. Collections plural, identities singular.
14
+ */
15
+ export type ResourceKey =
16
+ // Collections (plural) — lists of entities
17
+ | { type: "members"; orgId: string }
18
+ | { type: "departments"; orgId: string }
19
+ | { type: "chats"; orgId: string }
20
+ | { type: "teams"; orgId: string }
21
+ | { type: "integrations"; orgId: string }
22
+ | { type: "invites"; orgId: string }
23
+ | { type: "auditEvents"; orgId: string }
24
+ | { type: "timeline"; orgId: string }
25
+ // Identities (singular) — single entities
26
+ | { type: "member"; orgId: string; memberId: string }
27
+ | { type: "team"; orgId: string; teamId: string }
28
+ | { type: "department"; orgId: string; departmentId: string }
29
+ | { type: "chat"; orgId: string; chatId: string }
30
+ | { type: "companyMdDoc"; orgId: string; slug: string }
31
+ | { type: "companyMdContextBank"; orgId: string; slug: string }
32
+ // The access requests filed against ONE document — the owner's inbox, read by
33
+ // the Share dialog.
34
+ //
35
+ // Discriminated by `docId`, NOT `slug`, and the difference is load-bearing.
36
+ // `companyMdDoc` above carries the stable doc id under a field named `slug`
37
+ // for historical reasons; ADR-BE-315 made slugs only PARENT-SCOPED unique, so
38
+ // a bare slug does not identify a document. That field name is a wart to be
39
+ // contained, not propagated — a new key gets the honest name.
40
+ | { type: "companyMdAccessRequests"; orgId: string; docId: string }
41
+ // The sealed version history of ONE document (PRD-00927) — the metadata-only
42
+ // list the history panel pages through. Keyed by `docId` for the same reason
43
+ // `companyMdAccessRequests` is: the honest name for the stable document id,
44
+ // rather than propagating `companyMdDoc`'s legacy `slug` field name.
45
+ | { type: "companyMdDocVersions"; orgId: string; docId: string }
46
+ // ONE sealed version, body included.
47
+ //
48
+ // A member of its own rather than a `versionId` segment folded into the list
49
+ // key above, and the split is load-bearing. A restore APPENDS to the history,
50
+ // so it must invalidate `companyMdDocVersions` — and because a sealed version
51
+ // is immutable, every already-fetched body must SURVIVE that invalidation.
52
+ // Fold the two together and each restore evicts the whole set of historical
53
+ // bodies the reader just paged through, to re-fetch content that cannot have
54
+ // changed.
55
+ | {
56
+ type: "companyMdDocVersion";
57
+ orgId: string;
58
+ docId: string;
59
+ versionId: string;
60
+ }
61
+ // The comment threads hanging off ONE subject (ADR-CONTRACTS-116).
62
+ //
63
+ // Registered here rather than declared app-locally because THE KEY REGISTRY IS
64
+ // THE SSE COALESCER'S VOCABULARY: a `resource.invalidated` frame names a key,
65
+ // and a key the registry does not know cannot be routed through the coalescer
66
+ // at all. An app-local "commentThreads" would be a second vocabulary the
67
+ // server could never address.
68
+ //
69
+ // The only member with a TWO-SEGMENT identity, and it has to be: a thread
70
+ // list is addressed by (subjectType, subjectId), and the id alone is not
71
+ // enough — subject ids are per-class uuids, so two classes could collide and
72
+ // one subject's invalidation would flush another's cache. `subjectType` is
73
+ // typed `string` and not the closed `CommentSubjectType`: this is a cache
74
+ // vocabulary, `fromQueryKey` reconstructs keys from arbitrary wire strings,
75
+ // and narrowing it here would make that reconstruction a lie.
76
+ | {
77
+ type: "commentThreads";
78
+ orgId: string;
79
+ subjectType: string;
80
+ subjectId: string;
81
+ }
82
+ | { type: "workspace"; orgId: string }
83
+ | { type: "workspaceDomains"; orgId: string }
84
+ | { type: "authSettings"; orgId: string }
85
+ | { type: "billing"; orgId: string }
86
+ | { type: "aiUsage"; orgId: string }
87
+ | { type: "deletionEligibility"; orgId: string }
88
+ | { type: "transferOwnership"; orgId: string }
89
+ | { type: "companyMdDocs"; orgId: string }
90
+ // Directly-granted (non-inherited) ACL rows for the org, read by the admin
91
+ // grants surface (ADR-CONTRACTS-119).
92
+ | { type: "directGrants"; orgId: string }
93
+ // OrgUnit canonical model (ADR-BE-120) — Phase 2 Wave 4
94
+ | { type: "orgTree"; orgId: string }
95
+ | { type: "orgLevelConfig"; orgId: string }
96
+ | { type: "orgUnit"; orgId: string; unitId: string }
97
+ | { type: "orgUnitChildren"; orgId: string; unitId: string }
98
+ | { type: "orgUnitAncestors"; orgId: string; unitId: string }
99
+ | { type: "orgUnitMemberships"; orgId: string; unitId: string }
100
+ | { type: "orgUnitPermissions"; orgId: string; unitId: string }
101
+ // Open roles seated in a unit (ADR-BE-277) — a per-unit list, unitId-scoped
102
+ // like memberships/permissions. Read in the unit members view and the
103
+ // org-chart card; refreshed as a graph target of orgTree on structural
104
+ // mutations (create / advance lifecycle / fill). See ADR-CONTRACTS-065.
105
+ | { type: "orgUnitOpenRoles"; orgId: string; unitId: string }
106
+ // Org-unit owners list (ADR-CONTRACTS-052) — owners are an org-wide
107
+ // projection, not a per-unit collection, so unitId is intentionally excluded.
108
+ | { type: "orgUnitOwners"; orgId: string }
109
+ // THIS VIEWER'S authority over one unit (ADR-CONTRACTS-119) — what the viewer
110
+ // may do here, not what the unit's permission rows say. Deliberately distinct
111
+ // from `orgUnitPermissions`: same subject, different resource and different
112
+ // invalidation trigger (a membership change alters the viewer's authority
113
+ // without touching the unit's rows), the `actionItems`/`feed` split above.
114
+ | { type: "orgUnitMyAuthority"; orgId: string; unitId: string }
115
+ // People reporting (ADR-BE-166) — drives the settings Org chart drill-down
116
+ | { type: "peopleOrgChart"; orgId: string }
117
+ // System-scoped (ADR-CONTRACTS-052) — tenant-less super-admin resources.
118
+ // No orgId/userId: these live above any single org. scope is the literal 'system'.
119
+ | { type: "internalAdminAiProviders"; scope: "system" }
120
+ | { type: "internalAdminPrompts"; scope: "system" }
121
+ | { type: "internalAdminAiRuntimeDefaults"; scope: "system" }
122
+ // Software-factory surfaces (ADR-BE-239 / ADR-BE-243) — internal-admin
123
+ // dashboard reads over Global-infra factory tables; tenant-less, scope 'system'.
124
+ | { type: "factoryFloor"; scope: "system" }
125
+ | { type: "factorySnapshot"; scope: "system" }
126
+ // The KPI rollup over the same factory tables (ADR-CONTRACTS-119). Registered
127
+ // late: the app shipped it as a double-cast key literal, and because
128
+ // `useResource` disables a query whose `toQueryKey` throws, the panel it backs
129
+ // never fetched at all. An unregistered key is not a stale read, it is no read.
130
+ | { type: "factoryKpis"; scope: "system" }
131
+ // Everything awaiting the viewer's decision, across every domain
132
+ // (ADR-CONT-104). ORG-scoped, not user-scoped, even though the content is
133
+ // per-viewer: the bucket differs per org, so a user-keyed entry would serve
134
+ // one org's items after switching to another. Same shape as `chats` and
135
+ // `timeline`, which are likewise viewer-filtered but org-partitioned — and it
136
+ // inherits `resolveScope`'s impersonation handling for free.
137
+ | { type: "actionItems"; orgId: string }
138
+ // The merged top-of-/me/work read: action items AND the durable inbox
139
+ // (ADR-CONT-108). Org-scoped for the same reason `actionItems` is — the
140
+ // content is per-viewer but the bucket differs per org, so a user-keyed entry
141
+ // would serve one org's feed after switching to another.
142
+ //
143
+ // Distinct from `actionItems` rather than replacing it: that key still backs
144
+ // every badge in the shell, and the two have different invalidation triggers
145
+ // (a notification being READ changes the feed and not the bucket).
146
+ | { type: "feed"; orgId: string }
147
+ // User-scoped
148
+ | { type: "dismissedBanners"; userId: string }
149
+ | { type: "userOrgs"; userId: string }
150
+ | { type: "sessions"; userId: string }
151
+ // The viewer's personal doc (`/api/me/md`) — a per-user singleton, so it is
152
+ // keyed by userId alone with no doc identity segment. Its PAYLOAD is a work
153
+ // item, lazily materialized on first read (ADR-BE-487), but the resource is
154
+ // "this user's personal doc", not "work item <id>": a future generic
155
+ // `workItem` key would be a different resource with a different identity.
156
+ | { type: "userMd"; userId: string }
157
+ | { type: "viewer"; userId: string };
@@ -1,131 +1,14 @@
1
1
  /**
2
- * ResourceKey — shared vocabulary for resources across the system.
3
- * All keys MUST include scope (orgId or userId) to prevent cross-org cache leaks.
4
- * Naming: camelCase nouns. Collections plural, identities singular.
2
+ * ResourceKey operations scope resolution, the query-key round trip, and
3
+ * shape/identity matching.
4
+ *
5
+ * The union itself lives in `resource-key-types.ts` and is re-exported here, so
6
+ * `./resource-keys` remains the one import path for the whole vocabulary
7
+ * (the package barrel and every consumer reach for it through this module).
5
8
  */
6
- export type ResourceKey =
7
- // Collections (plural) lists of entities
8
- | { type: "members"; orgId: string }
9
- | { type: "departments"; orgId: string }
10
- | { type: "chats"; orgId: string }
11
- | { type: "teams"; orgId: string }
12
- | { type: "integrations"; orgId: string }
13
- | { type: "invites"; orgId: string }
14
- | { type: "auditEvents"; orgId: string }
15
- | { type: "timeline"; orgId: string }
16
- // Identities (singular) — single entities
17
- | { type: "member"; orgId: string; memberId: string }
18
- | { type: "team"; orgId: string; teamId: string }
19
- | { type: "department"; orgId: string; departmentId: string }
20
- | { type: "chat"; orgId: string; chatId: string }
21
- | { type: "companyMdDoc"; orgId: string; slug: string }
22
- | { type: "companyMdContextBank"; orgId: string; slug: string }
23
- // The access requests filed against ONE document — the owner's inbox, read by
24
- // the Share dialog.
25
- //
26
- // Discriminated by `docId`, NOT `slug`, and the difference is load-bearing.
27
- // `companyMdDoc` above carries the stable doc id under a field named `slug`
28
- // for historical reasons; ADR-BE-315 made slugs only PARENT-SCOPED unique, so
29
- // a bare slug does not identify a document. That field name is a wart to be
30
- // contained, not propagated — a new key gets the honest name.
31
- | { type: "companyMdAccessRequests"; orgId: string; docId: string }
32
- // The comment threads hanging off ONE subject (ADR-CONTRACTS-116).
33
- //
34
- // Registered here rather than declared app-locally because THE KEY REGISTRY IS
35
- // THE SSE COALESCER'S VOCABULARY: a `resource.invalidated` frame names a key,
36
- // and a key the registry does not know cannot be routed through the coalescer
37
- // at all. An app-local "commentThreads" would be a second vocabulary the
38
- // server could never address.
39
- //
40
- // The only member with a TWO-SEGMENT identity, and it has to be: a thread
41
- // list is addressed by (subjectType, subjectId), and the id alone is not
42
- // enough — subject ids are per-class uuids, so two classes could collide and
43
- // one subject's invalidation would flush another's cache. `subjectType` is
44
- // typed `string` and not the closed `CommentSubjectType`: this is a cache
45
- // vocabulary, `fromQueryKey` reconstructs keys from arbitrary wire strings,
46
- // and narrowing it here would make that reconstruction a lie.
47
- | {
48
- type: "commentThreads";
49
- orgId: string;
50
- subjectType: string;
51
- subjectId: string;
52
- }
53
- | { type: "workspace"; orgId: string }
54
- | { type: "workspaceDomains"; orgId: string }
55
- | { type: "authSettings"; orgId: string }
56
- | { type: "billing"; orgId: string }
57
- | { type: "aiUsage"; orgId: string }
58
- | { type: "deletionEligibility"; orgId: string }
59
- | { type: "transferOwnership"; orgId: string }
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 }
64
- // OrgUnit canonical model (ADR-BE-120) — Phase 2 Wave 4
65
- | { type: "orgTree"; orgId: string }
66
- | { type: "orgLevelConfig"; orgId: string }
67
- | { type: "orgUnit"; orgId: string; unitId: string }
68
- | { type: "orgUnitChildren"; orgId: string; unitId: string }
69
- | { type: "orgUnitAncestors"; orgId: string; unitId: string }
70
- | { type: "orgUnitMemberships"; orgId: string; unitId: string }
71
- | { type: "orgUnitPermissions"; orgId: string; unitId: string }
72
- // Open roles seated in a unit (ADR-BE-277) — a per-unit list, unitId-scoped
73
- // like memberships/permissions. Read in the unit members view and the
74
- // org-chart card; refreshed as a graph target of orgTree on structural
75
- // mutations (create / advance lifecycle / fill). See ADR-CONTRACTS-065.
76
- | { type: "orgUnitOpenRoles"; orgId: string; unitId: string }
77
- // Org-unit owners list (ADR-CONTRACTS-052) — owners are an org-wide
78
- // projection, not a per-unit collection, so unitId is intentionally excluded.
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 }
86
- // People reporting (ADR-BE-166) — drives the settings Org chart drill-down
87
- | { type: "peopleOrgChart"; orgId: string }
88
- // System-scoped (ADR-CONTRACTS-052) — tenant-less super-admin resources.
89
- // No orgId/userId: these live above any single org. scope is the literal 'system'.
90
- | { type: "internalAdminAiProviders"; scope: "system" }
91
- | { type: "internalAdminPrompts"; scope: "system" }
92
- | { type: "internalAdminAiRuntimeDefaults"; scope: "system" }
93
- // Software-factory surfaces (ADR-BE-239 / ADR-BE-243) — internal-admin
94
- // dashboard reads over Global-infra factory tables; tenant-less, scope 'system'.
95
- | { type: "factoryFloor"; scope: "system" }
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" }
102
- // Everything awaiting the viewer's decision, across every domain
103
- // (ADR-CONT-104). ORG-scoped, not user-scoped, even though the content is
104
- // per-viewer: the bucket differs per org, so a user-keyed entry would serve
105
- // one org's items after switching to another. Same shape as `chats` and
106
- // `timeline`, which are likewise viewer-filtered but org-partitioned — and it
107
- // inherits `resolveScope`'s impersonation handling for free.
108
- | { type: "actionItems"; orgId: string }
109
- // The merged top-of-/me/work read: action items AND the durable inbox
110
- // (ADR-CONT-108). Org-scoped for the same reason `actionItems` is — the
111
- // content is per-viewer but the bucket differs per org, so a user-keyed entry
112
- // would serve one org's feed after switching to another.
113
- //
114
- // Distinct from `actionItems` rather than replacing it: that key still backs
115
- // every badge in the shell, and the two have different invalidation triggers
116
- // (a notification being READ changes the feed and not the bucket).
117
- | { type: "feed"; orgId: string }
118
- // User-scoped
119
- | { type: "dismissedBanners"; userId: string }
120
- | { type: "userOrgs"; userId: string }
121
- | { type: "sessions"; userId: string }
122
- // The viewer's personal doc (`/api/me/md`) — a per-user singleton, so it is
123
- // keyed by userId alone with no doc identity segment. Its PAYLOAD is a work
124
- // item, lazily materialized on first read (ADR-BE-487), but the resource is
125
- // "this user's personal doc", not "work item <id>": a future generic
126
- // `workItem` key would be a different resource with a different identity.
127
- | { type: "userMd"; userId: string }
128
- | { type: "viewer"; userId: string };
9
+
10
+ export type { ResourceKey } from "./resource-key-types";
11
+ import type { ResourceKey } from "./resource-key-types";
129
12
 
130
13
  /**
131
14
  * Action — structured mutation key used across execution system, audit, permissions.
@@ -213,6 +96,7 @@ const IDENTITY_FIELDS = {
213
96
  companyMdDoc: "slug",
214
97
  companyMdContextBank: "slug",
215
98
  companyMdAccessRequests: "docId",
99
+ companyMdDocVersions: "docId",
216
100
  orgUnit: "unitId",
217
101
  orgUnitChildren: "unitId",
218
102
  orgUnitAncestors: "unitId",
@@ -233,6 +117,7 @@ const IDENTITY_FIELDS = {
233
117
  */
234
118
  const COMPOSITE_IDENTITY_FIELDS = {
235
119
  commentThreads: ["subjectType", "subjectId"],
120
+ companyMdDocVersion: ["docId", "versionId"],
236
121
  } as const satisfies {
237
122
  [T in ResourceKey["type"]]?: readonly IdentityFieldsOf<T>[];
238
123
  };
@@ -341,6 +226,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
341
226
  case "companyMdContextBank":
342
227
  return [key.type, key.orgId, key.slug] as const;
343
228
  case "companyMdAccessRequests":
229
+ case "companyMdDocVersions":
344
230
  return [key.type, key.orgId, key.docId] as const;
345
231
 
346
232
  // Composite identity — subject CLASS then subject id, in that order, so the
@@ -349,6 +235,12 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
349
235
  case "commentThreads":
350
236
  return [key.type, key.orgId, key.subjectType, key.subjectId] as const;
351
237
 
238
+ // Composite identity — the document, then the version within it. Outer
239
+ // scope first, same ordering rule as `commentThreads`, so the segments read
240
+ // in the order the URL that produced them spells: /docs/{id}/versions/{v}.
241
+ case "companyMdDocVersion":
242
+ return [key.type, key.orgId, key.docId, key.versionId] as const;
243
+
352
244
  // OrgUnit identity keys (ADR-BE-120)
353
245
  case "orgUnit":
354
246
  case "orgUnitChildren":
@@ -35,3 +35,72 @@ export const ResourceEntrySchema = z
35
35
 
36
36
  export type ResourceEntry = z.infer<typeof ResourceEntrySchema>;
37
37
  export type ResourceRegistry = ReadonlyMap<ResourceKey, ResourceEntry>;
38
+
39
+ /**
40
+ * The shape a row is WRITTEN in, before the schema's branding transforms run.
41
+ *
42
+ * `ResourceEntry` is the PARSED form: `resource`, `staleTimeMs` and friends come
43
+ * out branded, and nothing hand-authored can ever be assignable to a brand. So
44
+ * `z.input` is the only type a literal row can be checked against — which is
45
+ * what makes the `satisfies` below a real check and not decoration.
46
+ */
47
+ export type ResourceEntryInput = z.input<typeof ResourceEntrySchema>;
48
+
49
+ /**
50
+ * Company.md version-history rows (PRD-00927) — the first rows this registry has
51
+ * carried since it was introduced as a schema with no entries.
52
+ *
53
+ * Exported as CHECKED LITERALS rather than a built `ResourceRegistry` Map on
54
+ * purpose. Building the Map means running `ResourceEntrySchema.parse` at module
55
+ * load to obtain the branded values, and this package is types-first: every
56
+ * consumer would pay that at import time to read a table that is fully known at
57
+ * compile time. `satisfies readonly ResourceEntryInput[]` gets the same
58
+ * field-level enforcement for free, and a consumer that wants the Map can parse
59
+ * these rows itself.
60
+ *
61
+ * TWO VOCABULARY CAVEATS, both deliberate and both for ADR-CONTRACTS-120:
62
+ *
63
+ * 1. `resource` here is the {@link ResourceKey} type TAG (`companyMdDocVersions`).
64
+ * The registry that currently holds live rows is the app's copy, and its
65
+ * `resource` values are PATH-shaped (`company-md/docs`, `orgs/:orgId/billing`).
66
+ * The two do not line up, and reconciling them is not this PRD's work —
67
+ * naming these rows after the keys they describe is the only choice that is
68
+ * self-consistent here.
69
+ * 2. `hydrationDepends` names only rows in THIS array. Pointing at the app's
70
+ * path-shaped names would be a dangling reference across that same gap.
71
+ */
72
+ export const COMPANY_MD_VERSION_RESOURCE_ENTRIES = [
73
+ {
74
+ // The history panel is opened, never hydrated with the page — hence
75
+ // background/P3. A restore appends to this list, so it is the row that
76
+ // takes the invalidation.
77
+ resource: "companyMdDocVersions",
78
+ priority: "P3",
79
+ hydrationPhase: "background",
80
+ hydrationDepends: [],
81
+ mutationBehavior: "collapse",
82
+ staleTimeMs: 30_000,
83
+ degradationSection: "core",
84
+ queryBudgetMs: 5_000,
85
+ resourceFairnessCap: 1,
86
+ },
87
+ {
88
+ // A sealed version is immutable, which is what makes the long staleTime
89
+ // safe rather than merely convenient: there is no edit that could make an
90
+ // already-fetched body wrong. Reached only through the list, hence the
91
+ // dependency.
92
+ //
93
+ // `serial`, not `collapse`: the mutation that targets this resource is
94
+ // restore, and it is guarded by `expectedRevision` — two in flight at once
95
+ // means one of them is guaranteed a 409 for no reason.
96
+ resource: "companyMdDocVersion",
97
+ priority: "P3",
98
+ hydrationPhase: "background",
99
+ hydrationDepends: ["companyMdDocVersions"],
100
+ mutationBehavior: "serial",
101
+ staleTimeMs: 300_000,
102
+ degradationSection: "core",
103
+ queryBudgetMs: 5_000,
104
+ resourceFairnessCap: 1,
105
+ },
106
+ ] satisfies readonly ResourceEntryInput[];