@company-semantics/contracts 45.2.0 → 45.3.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.
Files changed (31) hide show
  1. package/package.json +9 -2
  2. package/src/__tests__/resource-keys.test.ts +59 -0
  3. package/src/api/generated-spec-hash.ts +2 -2
  4. package/src/api/generated.ts +93 -2
  5. package/src/comments/README.md +115 -0
  6. package/src/comments/__tests__/README.md +49 -0
  7. package/src/comments/__tests__/anchor-corpus.test.ts +202 -0
  8. package/src/comments/__tests__/fixtures/README.md +58 -0
  9. package/src/comments/__tests__/fixtures/comment-anchors.json +94 -0
  10. package/src/comments/__tests__/fixtures/comment-anchors.provenance.json +9 -0
  11. package/src/comments/__tests__/schemas.test.ts +274 -0
  12. package/src/comments/anchor.ts +120 -0
  13. package/src/comments/index.ts +43 -0
  14. package/src/comments/schemas.ts +227 -0
  15. package/src/generated/openapi-routes.ts +1 -0
  16. package/src/index.ts +42 -0
  17. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +4 -0
  18. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +741 -0
  19. package/src/notifications/__tests__/definition.test.ts +4 -3
  20. package/src/notifications/__tests__/fixtures.ts +34 -0
  21. package/src/notifications/__tests__/kinds.test.ts +11 -4
  22. package/src/notifications/__tests__/registry.test.ts +7 -5
  23. package/src/notifications/__tests__/render-snapshot.test.ts +36 -0
  24. package/src/notifications/kinds/comment-mention.ts +82 -0
  25. package/src/notifications/kinds/comment-reply.ts +79 -0
  26. package/src/notifications/kinds/index.ts +2 -0
  27. package/src/notifications/kinds.ts +12 -3
  28. package/src/notifications/payloads.ts +49 -0
  29. package/src/notifications/registry.ts +6 -2
  30. package/src/resource-keys.ts +68 -0
  31. package/src/user-notifications/kinds.ts +13 -1
@@ -110,10 +110,11 @@ describe("compose reads ambient state from the context", () => {
110
110
  brand: { name: "Umbrella Corp", copyrightYear: 2026 },
111
111
  });
112
112
 
113
- // Ten of the eleven kinds footer "sent via <brand>"; security.alert has no
114
- // notice at all, on purpose.
113
+ // Every kind but ONE footers "sent via <brand>"; security.alert has no
114
+ // notice at all, on purpose. The literal count is the tripwire: a kind
115
+ // added without a branded notice lands here rather than in production.
115
116
  const branded = KINDS.filter((kind) => kind !== "security.alert");
116
- expect(branded).toHaveLength(10);
117
+ expect(branded).toHaveLength(12);
117
118
 
118
119
  for (const kind of branded) {
119
120
  const notices = elementsOf(compose(kind, context)).filter(
@@ -238,5 +238,39 @@ add("companyMd.access_request_denied", "No reason", {
238
238
  docTitle: "Engineering Handbook",
239
239
  });
240
240
 
241
+ // Both comment kinds vary on the SAME axis — whether the thread is anchored to
242
+ // a passage — because that is the only branch either `compose` has.
243
+ add("comment.mention", "Text-anchored", {
244
+ actorName: "Jordan Lee",
245
+ recipientName: "Sam Chen",
246
+ subjectTitle: "Engineering Handbook",
247
+ quotedText: "ship the quarterly goals",
248
+ commentExcerpt:
249
+ "Should this say Q3 or Q4? @Sam Chen you owned this last time.",
250
+ threadUrl: `${APP}/doc/handbook?thread=th_123`,
251
+ });
252
+ add("comment.mention", "Document-level", {
253
+ actorName: "Jordan Lee",
254
+ subjectTitle: "Engineering Handbook",
255
+ commentExcerpt:
256
+ "Adding @Sam Chen — this whole doc needs a pass before Friday.",
257
+ threadUrl: `${APP}/doc/handbook?thread=th_456`,
258
+ });
259
+
260
+ add("comment.reply", "Text-anchored", {
261
+ actorName: "Alex Rivera",
262
+ recipientName: "Sam Chen",
263
+ subjectTitle: "Engineering Handbook",
264
+ quotedText: "ship the quarterly goals",
265
+ commentExcerpt: "Q4 — we moved it at the last planning session.",
266
+ threadUrl: `${APP}/doc/handbook?thread=th_123`,
267
+ });
268
+ add("comment.reply", "Document-level", {
269
+ actorName: "Alex Rivera",
270
+ subjectTitle: "Engineering Handbook",
271
+ commentExcerpt: "Started on the onboarding section.",
272
+ threadUrl: `${APP}/doc/handbook?thread=th_456`,
273
+ });
274
+
241
275
  /** The corpus — every implemented kind × representative variants. */
242
276
  export const NOTIFICATION_FIXTURES: readonly NotificationFixture[] = fixtures;
@@ -1,7 +1,10 @@
1
1
  /**
2
- * `NotificationKind` is the migration's central claim: that the eleven things
3
- * the email layer could actually SAY are notifications, not emails
4
- * (ADR-CONTRACTS-085).
2
+ * `NotificationKind` is the migration's central claim: that the things the
3
+ * email layer could actually SAY are notifications, not emails
4
+ * (ADR-CONTRACTS-085). It has grown since — `comment.mention` and
5
+ * `comment.reply` joined with ADR-CONTRACTS-116 — which is the ordinary way
6
+ * this union changes and exactly what the frozen list below is here to make
7
+ * visible.
5
8
  *
6
9
  * WHAT THESE TESTS USED TO DO, AND WHY THEY NO LONGER DO IT. Membership was
7
10
  * asserted against `IMPLEMENTED_EMAIL_KINDS`, and the union's deliberate
@@ -49,15 +52,19 @@ const ALL_NOTIFICATION_KINDS: Record<NotificationKind, true> = {
49
52
  "companyMd.access_requested": true,
50
53
  "companyMd.access_request_approved": true,
51
54
  "companyMd.access_request_denied": true,
55
+ "comment.mention": true,
56
+ "comment.reply": true,
52
57
  };
53
58
 
54
59
  const KINDS = Object.keys(ALL_NOTIFICATION_KINDS).sort();
55
60
 
56
61
  describe("NotificationKind", () => {
57
- it("has exactly the eleven kinds with something to say", () => {
62
+ it("has exactly the kinds with something to say", () => {
58
63
  expect(KINDS).toEqual([
59
64
  "auth.otp",
60
65
  "chat.shared",
66
+ "comment.mention",
67
+ "comment.reply",
61
68
  "companyMd.access_request_approved",
62
69
  "companyMd.access_request_denied",
63
70
  "companyMd.access_requested",
@@ -9,9 +9,9 @@
9
9
  * The new one is TOTALITY. `getEmailKindDefinition` could hand back a definition
10
10
  * whose kind then threw at render; `getNotificationKindDefinition` cannot,
11
11
  * because a kind with no `compose` cannot reach the union. That is the whole
12
- * reason `NotificationKind` has eleven members where `EmailKind` had twelve, and
13
- * it is only true as long as every definition here actually composes — which the
14
- * last test checks by calling all of them rather than trusting the type.
12
+ * reason `NotificationKind` excluded a member `EmailKind` had, and it is only
13
+ * true as long as every definition here actually composes — which the last test
14
+ * checks by calling all of them rather than trusting the type.
15
15
  *
16
16
  * And the ABSENCES: `subject`, `plainTextRequired` and `htmlSupported` are gone.
17
17
  * Asserting a field is missing looks pedantic until someone re-adds `subject`
@@ -51,6 +51,8 @@ describe("NOTIFICATION_DEFINITIONS", () => {
51
51
  expect([...KINDS].sort()).toEqual([
52
52
  "auth.otp",
53
53
  "chat.shared",
54
+ "comment.mention",
55
+ "comment.reply",
54
56
  "companyMd.access_request_approved",
55
57
  "companyMd.access_request_denied",
56
58
  "companyMd.access_requested",
@@ -86,7 +88,7 @@ describe("NOTIFICATION_DEFINITIONS", () => {
86
88
 
87
89
  it("names no destination for any kind", () => {
88
90
  // Capability is asked of a renderer, never declared on a definition —
89
- // otherwise every new surface is a rewrite of all eleven definitions.
91
+ // otherwise every new surface is a rewrite of every definition.
90
92
  for (const definition of Object.values(NOTIFICATION_DEFINITIONS)) {
91
93
  expect(Object.keys(definition).sort()).toEqual(["compose", "kind"]);
92
94
  }
@@ -94,7 +96,7 @@ describe("NOTIFICATION_DEFINITIONS", () => {
94
96
 
95
97
  it("composes for every kind, so the registry is total in practice and not just in type", () => {
96
98
  // The claim that makes `getNotificationKindDefinition` safe where
97
- // `getEmailKindDefinition` was not. Calling all eleven is the only way to
99
+ // `getEmailKindDefinition` was not. Calling every one is the only way to
98
100
  // know it: a type says a `compose` exists, not that it returns content.
99
101
  for (const kind of KINDS) {
100
102
  const compose = NOTIFICATION_DEFINITIONS[kind].compose as (
@@ -238,6 +238,42 @@ add("companyMd.access_request_denied", "No reason", {
238
238
  docTitle: "Engineering Handbook",
239
239
  });
240
240
 
241
+ // The anchored/unanchored branch, across every channel: the `Passage` row is
242
+ // present only when the payload carries a quote, because a document-level
243
+ // thread is anchored to nothing in particular and an empty row would state
244
+ // something false rather than omit something absent.
245
+ add("comment.mention", "Text-anchored", {
246
+ actorName: "Jordan Lee",
247
+ recipientName: "Sam Chen",
248
+ subjectTitle: "Engineering Handbook",
249
+ quotedText: "ship the quarterly goals",
250
+ commentExcerpt:
251
+ "Should this say Q3 or Q4? @Sam Chen you owned this last time.",
252
+ threadUrl: `${APP}/doc/handbook?thread=th_123`,
253
+ });
254
+ add("comment.mention", "Document-level", {
255
+ actorName: "Jordan Lee",
256
+ subjectTitle: "Engineering Handbook",
257
+ commentExcerpt:
258
+ "Adding @Sam Chen — this whole doc needs a pass before Friday.",
259
+ threadUrl: `${APP}/doc/handbook?thread=th_456`,
260
+ });
261
+
262
+ add("comment.reply", "Text-anchored", {
263
+ actorName: "Alex Rivera",
264
+ recipientName: "Sam Chen",
265
+ subjectTitle: "Engineering Handbook",
266
+ quotedText: "ship the quarterly goals",
267
+ commentExcerpt: "Q4 — we moved it at the last planning session.",
268
+ threadUrl: `${APP}/doc/handbook?thread=th_123`,
269
+ });
270
+ add("comment.reply", "Document-level", {
271
+ actorName: "Alex Rivera",
272
+ subjectTitle: "Engineering Handbook",
273
+ commentExcerpt: "Started on the onboarding section.",
274
+ threadUrl: `${APP}/doc/handbook?thread=th_456`,
275
+ });
276
+
241
277
  // "email render", not "notification render": the snapshot keys are
242
278
  // `email render > {kind} · {name} 1`, and renaming the describe would orphan
243
279
  // every one of them — regenerating the whole .snap to prove nothing changed is
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Comment-mention notification — sent to someone named in a comment
3
+ * (ADR-CONTRACTS-116).
4
+ *
5
+ * Leads with WHO named you and WHERE, because that is what decides whether the
6
+ * recipient opens it; the comment text follows as a reproduced turn rather than
7
+ * a body paragraph, so a channel that can depict a conversation depicts one.
8
+ *
9
+ * The anchored passage is shown as a key-value row and only when the payload
10
+ * carries it: a document-level thread is anchored to nothing in particular, and
11
+ * a "Passage: —" row would state something false rather than omit something
12
+ * absent.
13
+ */
14
+
15
+ import type { NotificationDefinition } from "../definition";
16
+
17
+ export const commentMentionDefinition: NotificationDefinition<"comment.mention"> =
18
+ {
19
+ kind: "comment.mention",
20
+ compose: (payload, context) => {
21
+ const {
22
+ actorName,
23
+ recipientName,
24
+ subjectTitle,
25
+ quotedText,
26
+ commentExcerpt,
27
+ threadUrl,
28
+ } = payload;
29
+
30
+ return {
31
+ metadata: {
32
+ kind: "comment.mention",
33
+ title: `${actorName} mentioned you in a comment`,
34
+ },
35
+ sections: [
36
+ {
37
+ elements: [
38
+ { type: "greeting", ...(recipientName ? { recipientName } : {}) },
39
+ {
40
+ type: "body",
41
+ text: `${actorName} mentioned you in a comment on "${subjectTitle}".`,
42
+ },
43
+ {
44
+ type: "chatUnit",
45
+ items: [
46
+ {
47
+ type: "message",
48
+ role: "user",
49
+ text: commentExcerpt,
50
+ from: actorName,
51
+ },
52
+ {
53
+ type: "callToAction",
54
+ label: "VIEW COMMENT",
55
+ href: threadUrl,
56
+ },
57
+ ],
58
+ },
59
+ {
60
+ type: "keyValueTable",
61
+ rows: [
62
+ { label: "From", value: actorName },
63
+ { label: "On", value: `"${subjectTitle}"` },
64
+ ...(quotedText
65
+ ? [{ label: "Passage", value: `"${quotedText}"` }]
66
+ : []),
67
+ ],
68
+ },
69
+ {
70
+ type: "notice",
71
+ lines: [
72
+ `This notification was sent via ${context.brand.name}.`,
73
+ "You were named in this comment.",
74
+ ],
75
+ },
76
+ { type: "signature" },
77
+ ],
78
+ },
79
+ ],
80
+ };
81
+ },
82
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Comment-reply notification — sent to the other participants of a thread when
3
+ * it gets a new comment (ADR-CONTRACTS-116).
4
+ *
5
+ * Deliberately a near-twin of `./comment-mention` with a DIFFERENT closing
6
+ * line, and the difference is the whole point of keeping two definitions: a
7
+ * mention says "you were named", a reply says "a thread you took part in moved
8
+ * on". Collapsing them into one kind with a flag would put that editorial
9
+ * choice inside a conditional, where the two would slowly converge.
10
+ *
11
+ * The sender guarantees a person receives at most one of the two for any one
12
+ * comment; nothing here needs to know that, which is why neither payload
13
+ * carries the other's audience.
14
+ */
15
+
16
+ import type { NotificationDefinition } from "../definition";
17
+
18
+ export const commentReplyDefinition: NotificationDefinition<"comment.reply"> = {
19
+ kind: "comment.reply",
20
+ compose: (payload, context) => {
21
+ const {
22
+ actorName,
23
+ recipientName,
24
+ subjectTitle,
25
+ quotedText,
26
+ commentExcerpt,
27
+ threadUrl,
28
+ } = payload;
29
+
30
+ return {
31
+ metadata: {
32
+ kind: "comment.reply",
33
+ title: `${actorName} replied in a thread you are part of`,
34
+ },
35
+ sections: [
36
+ {
37
+ elements: [
38
+ { type: "greeting", ...(recipientName ? { recipientName } : {}) },
39
+ {
40
+ type: "body",
41
+ text: `${actorName} replied in a comment thread on "${subjectTitle}".`,
42
+ },
43
+ {
44
+ type: "chatUnit",
45
+ items: [
46
+ { type: "continuation" },
47
+ {
48
+ type: "message",
49
+ role: "user",
50
+ text: commentExcerpt,
51
+ from: actorName,
52
+ },
53
+ { type: "callToAction", label: "VIEW THREAD", href: threadUrl },
54
+ ],
55
+ },
56
+ {
57
+ type: "keyValueTable",
58
+ rows: [
59
+ { label: "From", value: actorName },
60
+ { label: "On", value: `"${subjectTitle}"` },
61
+ ...(quotedText
62
+ ? [{ label: "Passage", value: `"${quotedText}"` }]
63
+ : []),
64
+ ],
65
+ },
66
+ {
67
+ type: "notice",
68
+ lines: [
69
+ `This notification was sent via ${context.brand.name}.`,
70
+ "You are part of this thread.",
71
+ ],
72
+ },
73
+ { type: "signature" },
74
+ ],
75
+ },
76
+ ],
77
+ };
78
+ },
79
+ };
@@ -11,6 +11,8 @@ export { accessDeniedDefinition } from "./company-md-access-denied";
11
11
  export { accessRequestedDefinition } from "./company-md-access-requested";
12
12
  export { authOtpDefinition } from "./auth-otp";
13
13
  export { chatSharedDefinition } from "./chat-shared";
14
+ export { commentMentionDefinition } from "./comment-mention";
15
+ export { commentReplyDefinition } from "./comment-reply";
14
16
  export { orgInviteDefinition } from "./org-invite";
15
17
  export { ownershipTransferCompletedDefinition } from "./ownership-transfer-completed";
16
18
  export { ownershipTransferDefinition } from "./ownership-transfer";
@@ -33,8 +33,9 @@
33
33
  * - domain: auth, org, security, chat, share, companyMd
34
34
  * - type: specific notification variant
35
35
  *
36
- * This union is exactly the kinds with a `compose` implementation — the 11 the
37
- * email layer could render, and deliberately NOT the 12 its `EmailKind` had.
36
+ * This union is exactly the kinds with a `compose` implementation — it started
37
+ * as the 11 the email layer could render, and deliberately NOT the 12 its
38
+ * `EmailKind` had.
38
39
  * `auth.magic_link` was registered with a subject but never had a template, so
39
40
  * the old `renderEmail("auth.magic_link", …)` type-checked and then threw at
40
41
  * runtime. Excluding it closes that hole by construction: a kind IS its
@@ -65,4 +66,12 @@ export type NotificationKind =
65
66
  | "share.granted"
66
67
  | "companyMd.access_requested"
67
68
  | "companyMd.access_request_approved"
68
- | "companyMd.access_request_denied";
69
+ | "companyMd.access_request_denied"
70
+ // Comment activity (ADR-CONTRACTS-116). These two names also exist in
71
+ // `../user-notifications`'s `UserNotificationKind`, and the overlap is
72
+ // deliberate and already precedented by the three `companyMd.*` members
73
+ // above: the message that LEAVES THE BUILDING and the durable row you come
74
+ // back to are two different things about one event, with two lifecycles.
75
+ // Neither union is derived from the other.
76
+ | "comment.mention"
77
+ | "comment.reply";
@@ -158,6 +158,55 @@ export interface NotificationPayloads {
158
158
  */
159
159
  message?: string;
160
160
  };
161
+ /**
162
+ * Sent to someone named in a comment (ADR-CONTRACTS-116).
163
+ *
164
+ * FACTS ONLY, AND DELIBERATELY NOT THE ANCHOR. A notification says who,
165
+ * where and what was written; it does not carry the machinery for placing a
166
+ * highlight, because a composed message has no document to place it in. The
167
+ * anchor's `quote` is passed as ordinary prose (`quotedText`) precisely so
168
+ * this layer cannot grow an opinion about anchors.
169
+ */
170
+ "comment.mention": {
171
+ /** Display name of the person who wrote the comment naming the recipient. */
172
+ actorName: string;
173
+ /** Display name of the recipient (optional; falls back to a neutral greeting) */
174
+ recipientName?: string;
175
+ /** Human-readable label for the thing commented on, e.g. "Engineering Handbook". */
176
+ subjectTitle: string;
177
+ /**
178
+ * The passage the thread is anchored to, as plain text. Absent for a
179
+ * document-level thread, which is anchored to nothing in particular.
180
+ */
181
+ quotedText?: string;
182
+ /** The comment body, already excerpted by the sender. Never the whole thread. */
183
+ commentExcerpt: string;
184
+ /** Full URL that opens the subject with this thread selected. */
185
+ threadUrl: string;
186
+ };
187
+ /**
188
+ * Sent to the other participants of a thread when it gets a new comment
189
+ * (ADR-CONTRACTS-116).
190
+ *
191
+ * ONE COMMENT NEVER PRODUCES TWO MESSAGES FOR ONE PERSON: a participant who
192
+ * was also mentioned receives `comment.mention` and not this. The sender
193
+ * resolves that, not `compose` — which is why the two payloads are separate
194
+ * shapes rather than one with a flag.
195
+ */
196
+ "comment.reply": {
197
+ /** Display name of the person who replied. */
198
+ actorName: string;
199
+ /** Display name of the recipient (optional; falls back to a neutral greeting) */
200
+ recipientName?: string;
201
+ /** Human-readable label for the thing commented on, e.g. "Engineering Handbook". */
202
+ subjectTitle: string;
203
+ /** The passage the thread is anchored to. Absent for a document-level thread. */
204
+ quotedText?: string;
205
+ /** The reply body, already excerpted by the sender. */
206
+ commentExcerpt: string;
207
+ /** Full URL that opens the subject with this thread selected. */
208
+ threadUrl: string;
209
+ };
161
210
  /** Sent to the requester when an owner denies (ADR-BE-338). */
162
211
  "companyMd.access_request_denied": {
163
212
  /** Display name of the owner who denied */
@@ -27,6 +27,8 @@ import {
27
27
  accessRequestedDefinition,
28
28
  authOtpDefinition,
29
29
  chatSharedDefinition,
30
+ commentMentionDefinition,
31
+ commentReplyDefinition,
30
32
  orgInviteDefinition,
31
33
  ownershipTransferCompletedDefinition,
32
34
  ownershipTransferDefinition,
@@ -43,8 +45,8 @@ import {
43
45
  * The registry's shape: `Record<NotificationKind, NotificationDefinition>` made
44
46
  * per-kind precise.
45
47
  *
46
- * A plain `Record` would widen every `compose` to accept a union of all eleven
47
- * payloads, and — since `compose` is an arrow property, checked contravariantly
48
+ * A plain `Record` would widen every `compose` to accept a union of every
49
+ * payload, and — since `compose` is an arrow property, checked contravariantly
48
50
  * — no real definition would be assignable to it. The mapped type keeps each
49
51
  * kind bound to its own payload, which is what a caller wants anyway.
50
52
  */
@@ -75,6 +77,8 @@ export const NOTIFICATION_DEFINITIONS: NotificationDefinitions = {
75
77
  "companyMd.access_requested": accessRequestedDefinition,
76
78
  "companyMd.access_request_approved": accessApprovedDefinition,
77
79
  "companyMd.access_request_denied": accessDeniedDefinition,
80
+ "comment.mention": commentMentionDefinition,
81
+ "comment.reply": commentReplyDefinition,
78
82
  };
79
83
 
80
84
  // =============================================================================
@@ -29,6 +29,27 @@ export type ResourceKey =
29
29
  // a bare slug does not identify a document. That field name is a wart to be
30
30
  // contained, not propagated — a new key gets the honest name.
31
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
+ }
32
53
  | { type: "workspace"; orgId: string }
33
54
  | { type: "workspaceDomains"; orgId: string }
34
55
  | { type: "authSettings"; orgId: string }
@@ -180,6 +201,12 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
180
201
  case "companyMdAccessRequests":
181
202
  return [key.type, key.orgId, key.docId] as const;
182
203
 
204
+ // Composite identity — subject CLASS then subject id, in that order, so the
205
+ // two segments read the same way round as the query string that produced
206
+ // them.
207
+ case "commentThreads":
208
+ return [key.type, key.orgId, key.subjectType, key.subjectId] as const;
209
+
183
210
  // OrgUnit identity keys (ADR-BE-120)
184
211
  case "orgUnit":
185
212
  case "orgUnitChildren":
@@ -250,6 +277,33 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
250
277
  );
251
278
  }
252
279
 
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];
293
+ if (compositeFields) {
294
+ // +1 for the orgId that leads every composite key.
295
+ if (rest.length !== compositeFields.length + 1) {
296
+ throw new Error(
297
+ `Invalid query key for '${type}': expected [type, orgId, ${compositeFields.join(", ")}], got ${JSON.stringify(queryKey)}`,
298
+ );
299
+ }
300
+ const key: Record<string, string> = { type, orgId: rest[0] };
301
+ compositeFields.forEach((field, index) => {
302
+ key[field] = rest[index + 1];
303
+ });
304
+ return key as ResourceKey;
305
+ }
306
+
253
307
  // Identities with extra field
254
308
  const identityFields: Record<string, string> = {
255
309
  member: "memberId",
@@ -370,6 +424,20 @@ export function matchesResourceKey(
370
424
  parsed.unitId !== targetKey.unitId
371
425
  )
372
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;
373
441
  if (
374
442
  "scope" in parsed &&
375
443
  "scope" in targetKey &&
@@ -51,7 +51,7 @@
51
51
  * domain's own authority gate in bulk and returns a tombstone when the
52
52
  * reader may no longer see the referenced entity
53
53
  *
54
- * The three members below are the informational half of the company.md
54
+ * The first three members are the informational half of the company.md
55
55
  * request-access lifecycle. Note what is NOT here:
56
56
  * `companyMd.access_request_pending` is an ACTION ITEM — the owner can resolve
57
57
  * it, so it is standing state, and modelling it here as well would give one
@@ -64,6 +64,18 @@ export const USER_NOTIFICATION_KINDS = [
64
64
  "companyMd.access_request_approved",
65
65
  /** Your request was declined. */
66
66
  "companyMd.access_request_denied",
67
+ /**
68
+ * You were named in a comment (ADR-CONTRACTS-116).
69
+ *
70
+ * A fact you can only look at, so it belongs here and not in `ActionItemKind`
71
+ * — being mentioned asks nothing of you that a system can call resolved. The
72
+ * producing domain emits at most ONE row per person per comment: a mentioned
73
+ * participant gets this and NOT `comment.reply`, because the mention is the
74
+ * more specific fact and the one they were addressed by.
75
+ */
76
+ "comment.mention",
77
+ /** A thread you took part in got a new comment. Never emitted for an edit. */
78
+ "comment.reply",
67
79
  ] as const;
68
80
 
69
81
  export type UserNotificationKind = (typeof USER_NOTIFICATION_KINDS)[number];