@company-semantics/contracts 43.0.0 → 44.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": "43.0.0",
3
+ "version": "44.1.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '017f7c7f6a72' as const;
3
- export const SPEC_HASH_FULL = '017f7c7f6a72c27413c0329be3eb9ff39547d425f575d570970d6e6920324638' as const;
2
+ export const SPEC_HASH = '815c01c0b920' as const;
3
+ export const SPEC_HASH_FULL = '815c01c0b920961e20e9bbe921d37145daa661873ce15b6a3cb426d6761cf80f' as const;
@@ -2876,6 +2876,60 @@ export interface paths {
2876
2876
  patch?: never;
2877
2877
  trace?: never;
2878
2878
  };
2879
+ "/api/me/feed": {
2880
+ parameters: {
2881
+ query?: never;
2882
+ header?: never;
2883
+ path?: never;
2884
+ cookie?: never;
2885
+ };
2886
+ /**
2887
+ * What I owe and what happened, merged, newest first
2888
+ * @description Actionable rows first, then informational rows newest-first — grouped, not chronologically interleaved. Only the informational half paginates.
2889
+ */
2890
+ get: operations["listMyFeed"];
2891
+ put?: never;
2892
+ post?: never;
2893
+ delete?: never;
2894
+ options?: never;
2895
+ head?: never;
2896
+ patch?: never;
2897
+ trace?: never;
2898
+ };
2899
+ "/api/me/feed/read": {
2900
+ parameters: {
2901
+ query?: never;
2902
+ header?: never;
2903
+ path?: never;
2904
+ cookie?: never;
2905
+ };
2906
+ get?: never;
2907
+ put?: never;
2908
+ /** Mark specific notifications read */
2909
+ post: operations["markMyFeedRead"];
2910
+ delete?: never;
2911
+ options?: never;
2912
+ head?: never;
2913
+ patch?: never;
2914
+ trace?: never;
2915
+ };
2916
+ "/api/me/feed/read-all": {
2917
+ parameters: {
2918
+ query?: never;
2919
+ header?: never;
2920
+ path?: never;
2921
+ cookie?: never;
2922
+ };
2923
+ get?: never;
2924
+ put?: never;
2925
+ /** Mark every notification read */
2926
+ post: operations["markMyFeedAllRead"];
2927
+ delete?: never;
2928
+ options?: never;
2929
+ head?: never;
2930
+ patch?: never;
2931
+ trace?: never;
2932
+ };
2879
2933
  "/api/me/meetings/recordings/start": {
2880
2934
  parameters: {
2881
2935
  query?: never;
@@ -5513,6 +5567,51 @@ export interface components {
5513
5567
  createdAt: string;
5514
5568
  }[];
5515
5569
  };
5570
+ FeedListResponse: {
5571
+ items: ({
5572
+ /** @constant */
5573
+ row: "actionable";
5574
+ item: {
5575
+ id: string;
5576
+ /** @enum {string} */
5577
+ kind: "companyMd.access_request_pending" | "execution.confirmation_pending";
5578
+ target: {
5579
+ /** @enum {string} */
5580
+ type: "company_md" | "org_unit" | "org" | "execution";
5581
+ id: string;
5582
+ };
5583
+ unitPath: string | null;
5584
+ title: string;
5585
+ detail: string | null;
5586
+ contextLabel: string;
5587
+ href: string;
5588
+ createdAt: string;
5589
+ };
5590
+ } | {
5591
+ /** @constant */
5592
+ row: "informational";
5593
+ notification: {
5594
+ id: string;
5595
+ /** @enum {string} */
5596
+ kind: "companyMd.access_requested" | "companyMd.access_request_approved" | "companyMd.access_request_denied";
5597
+ target: {
5598
+ /** @enum {string} */
5599
+ type: "company_md" | "org_unit" | "org" | "execution";
5600
+ id: string;
5601
+ };
5602
+ unitPath: string | null;
5603
+ title: string;
5604
+ detail: string | null;
5605
+ contextLabel: string;
5606
+ href: string | null;
5607
+ createdAt: string;
5608
+ readAt: string | null;
5609
+ };
5610
+ })[];
5611
+ nextCursor: string | null;
5612
+ unreadCount: number;
5613
+ truncated: boolean;
5614
+ };
5516
5615
  /** @description Recording + work_item ids and the transcription session grant. */
5517
5616
  StartMeetingRecordingResponse: {
5518
5617
  /** @description ULID; unified trace key for the recording (INV-MTG-7). */
@@ -10766,6 +10865,62 @@ export interface operations {
10766
10865
  };
10767
10866
  };
10768
10867
  };
10868
+ listMyFeed: {
10869
+ parameters: {
10870
+ query?: never;
10871
+ header?: never;
10872
+ path?: never;
10873
+ cookie?: never;
10874
+ };
10875
+ requestBody?: never;
10876
+ responses: {
10877
+ /** @description One page of the merged feed for the current user */
10878
+ 200: {
10879
+ headers: {
10880
+ [name: string]: unknown;
10881
+ };
10882
+ content: {
10883
+ "application/json": components["schemas"]["FeedListResponse"];
10884
+ };
10885
+ };
10886
+ };
10887
+ };
10888
+ markMyFeedRead: {
10889
+ parameters: {
10890
+ query?: never;
10891
+ header?: never;
10892
+ path?: never;
10893
+ cookie?: never;
10894
+ };
10895
+ requestBody?: never;
10896
+ responses: {
10897
+ /** @description Read state applied to the actor’s own rows */
10898
+ 200: {
10899
+ headers: {
10900
+ [name: string]: unknown;
10901
+ };
10902
+ content?: never;
10903
+ };
10904
+ };
10905
+ };
10906
+ markMyFeedAllRead: {
10907
+ parameters: {
10908
+ query?: never;
10909
+ header?: never;
10910
+ path?: never;
10911
+ cookie?: never;
10912
+ };
10913
+ requestBody?: never;
10914
+ responses: {
10915
+ /** @description Read state applied to the actor’s own rows */
10916
+ 200: {
10917
+ headers: {
10918
+ [name: string]: unknown;
10919
+ };
10920
+ content?: never;
10921
+ };
10922
+ };
10923
+ };
10769
10924
  startMeetingRecording: {
10770
10925
  parameters: {
10771
10926
  query?: never;
@@ -75,6 +75,9 @@ export const openApiRoutes = {
75
75
  '/api/me': ['GET'],
76
76
  '/api/me/access-list': ['GET'],
77
77
  '/api/me/action-items': ['GET'],
78
+ '/api/me/feed': ['GET'],
79
+ '/api/me/feed/read': ['POST'],
80
+ '/api/me/feed/read-all': ['POST'],
78
81
  '/api/me/meetings/recordings/start': ['POST'],
79
82
  '/api/me/meetings/recordings/{id}': ['DELETE'],
80
83
  '/api/me/meetings/recordings/{id}/complete': ['POST'],
package/src/index.ts CHANGED
@@ -289,6 +289,7 @@ export {
289
289
  } from "./user-events/index";
290
290
 
291
291
  export type {
292
+ NotificationCreatedEvent,
292
293
  ResourceInvalidatedEvent,
293
294
  UserEventConnected,
294
295
  UserEventResync,
@@ -296,6 +297,31 @@ export type {
296
297
  UserSseEvent,
297
298
  } from "./user-events/index";
298
299
 
300
+ // Durable in-app inbox + the merged feed that renders it (ADR-CONT-108). The
301
+ // THIRD vocabulary beside NotificationKind and ActionItemKind: a notification
302
+ // leaves the building, an action item is standing state you can resolve, and
303
+ // one of these is a stored fact you can only look at. Rows hold identifiers
304
+ // only — every prose field is rendered at read time under the reader's CURRENT
305
+ // authority, which is what keeps a stale row from leaking anything.
306
+ // See src/user-notifications/README.md.
307
+ export { USER_NOTIFICATION_KINDS } from "./user-notifications/index";
308
+
309
+ export {
310
+ FeedItemSchema,
311
+ FeedListResponseSchema,
312
+ FeedMarkReadRequestSchema,
313
+ UserNotificationKindSchema,
314
+ UserNotificationSchema,
315
+ } from "./user-notifications/index";
316
+
317
+ export type {
318
+ FeedItem,
319
+ FeedListResponse,
320
+ FeedMarkReadRequest,
321
+ UserNotification,
322
+ UserNotificationKind,
323
+ } from "./user-notifications/index";
324
+
299
325
  // Chat domain types
300
326
  // @see PRD-00142 for share chat design rationale
301
327
  export type {
@@ -71,6 +71,15 @@ export type ResourceKey =
71
71
  // `timeline`, which are likewise viewer-filtered but org-partitioned — and it
72
72
  // inherits `resolveScope`'s impersonation handling for free.
73
73
  | { type: "actionItems"; orgId: string }
74
+ // The merged top-of-/me/work read: action items AND the durable inbox
75
+ // (ADR-CONT-108). Org-scoped for the same reason `actionItems` is — the
76
+ // content is per-viewer but the bucket differs per org, so a user-keyed entry
77
+ // would serve one org's feed after switching to another.
78
+ //
79
+ // Distinct from `actionItems` rather than replacing it: that key still backs
80
+ // every badge in the shell, and the two have different invalidation triggers
81
+ // (a notification being READ changes the feed and not the bucket).
82
+ | { type: "feed"; orgId: string }
74
83
  // User-scoped
75
84
  | { type: "dismissedBanners"; userId: string }
76
85
  | { type: "userOrgs"; userId: string }
@@ -115,6 +124,7 @@ const ORG_SCOPED_TYPES = [
115
124
  "peopleOrgChart",
116
125
  "orgUnitOwners",
117
126
  "actionItems",
127
+ "feed",
118
128
  ] as const;
119
129
 
120
130
  const USER_SCOPED_TYPES = [
@@ -201,6 +211,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
201
211
  case "peopleOrgChart":
202
212
  case "orgUnitOwners":
203
213
  case "actionItems":
214
+ case "feed":
204
215
  return [key.type, key.orgId] as const;
205
216
 
206
217
  // System-scoped (ADR-CONTRACTS-052) — tenant-less super-admin resources
@@ -7,6 +7,7 @@
7
7
 
8
8
  export {
9
9
  MAX_INVALIDATION_KEYS,
10
+ NotificationCreatedEventSchema,
10
11
  ResourceInvalidatedEventSchema,
11
12
  USER_EVENT_RESYNC_REASONS,
12
13
  UserEventBaseSchema,
@@ -17,6 +18,7 @@ export {
17
18
  } from "./schemas";
18
19
 
19
20
  export type {
21
+ NotificationCreatedEvent,
20
22
  ResourceInvalidatedEvent,
21
23
  UserEventConnected,
22
24
  UserEventResync,
@@ -134,6 +134,46 @@ export const UserEventResyncSchema = UserEventBaseSchema.partial({
134
134
  });
135
135
  export type UserEventResync = z.infer<typeof UserEventResyncSchema>;
136
136
 
137
+ // =============================================================================
138
+ // notification.created — an ACCELERATOR, never the source of truth
139
+ // =============================================================================
140
+
141
+ /**
142
+ * A durable notification row was written for this user.
143
+ *
144
+ * AN ACCELERATOR ONLY. The row is already committed and the inbox
145
+ * (`GET /api/me/feed`) is the authority; this frame exists so a connected
146
+ * client sees it without waiting for a refetch. A client that misses it loses
147
+ * nothing — the row is still there on the next read, which is exactly why this
148
+ * stream is allowed to stay best-effort.
149
+ *
150
+ * That is also why it carries only the id and the unread count, not the
151
+ * rendered notification. Rendering happens at READ TIME under the reader's
152
+ * current authority (see `UserNotificationSchema`); a pre-rendered payload on
153
+ * the wire would be a snapshot of authority taken at write time, which is the
154
+ * one thing that must not be cached. It would also put entity content on a
155
+ * `pg_notify` payload, which the transport forbids for its own reasons.
156
+ *
157
+ * There is deliberately NO `notification.read` counterpart. Read state is a
158
+ * mutable column on a row whose id was allocated at creation, so a read event
159
+ * has no valid position in a stream ordered by anything — it would either move
160
+ * an id backwards or reuse one already observed. Read state is reconciled from
161
+ * the authoritative query instead.
162
+ */
163
+ export const NotificationCreatedEventSchema = UserEventBaseSchema.partial({
164
+ v: true,
165
+ timestamp: true,
166
+ }).extend({
167
+ type: z.literal("notification.created"),
168
+ /** The row id, so a client that already has it can dedupe. */
169
+ notificationId: z.string(),
170
+ /** Total unread after this write, so the badge updates without a fetch. */
171
+ unreadCount: z.number().int().nonnegative(),
172
+ });
173
+ export type NotificationCreatedEvent = z.infer<
174
+ typeof NotificationCreatedEventSchema
175
+ >;
176
+
137
177
  // =============================================================================
138
178
  // connected — transport-level
139
179
  // =============================================================================
@@ -167,6 +207,7 @@ export type UserEventConnected = z.infer<typeof UserEventConnectedSchema>;
167
207
  */
168
208
  export const UserSseEventSchema = z.discriminatedUnion("type", [
169
209
  ResourceInvalidatedEventSchema,
210
+ NotificationCreatedEventSchema,
170
211
  UserEventResyncSchema,
171
212
  UserEventConnectedSchema,
172
213
  ]);
@@ -0,0 +1,72 @@
1
+ # user-notifications/
2
+
3
+ ## Purpose
4
+
5
+ The published shapes of the **durable in-app inbox** — informational facts
6
+ addressed to one user, which they can only look at — and of the **merged feed**
7
+ that renders them alongside action items at the top of `/me/work`.
8
+
9
+ ## Invariants
10
+
11
+ - **A stored row holds IDENTIFIERS ONLY**: `kind` plus a `ref` of ids. No
12
+ titles, no paths, no names, no bodies. Everything in `UserNotificationSchema`
13
+ that reads like prose is produced at READ TIME by the producing domain's
14
+ renderer, under the reader's CURRENT authority.
15
+ - **A stored row therefore never contains protected content**, so a stale row
16
+ cannot leak one. This is the invariant the whole design turns on: it is what
17
+ makes copy evolution a renderer change rather than a migration, and what makes
18
+ revocation a re-check rather than a backfill.
19
+ - **`href` is nullable and a tombstone is a legitimate render.** A reader who
20
+ lost access, or whose client cannot decode the stored `ref_version`, still
21
+ sees that the thing happened — it simply does not link anywhere. "You were
22
+ granted access to something you can no longer see" is true and worth saying.
23
+ - **Feed ordering is grouped, not chronologically interleaved.** Actionable rows
24
+ first, then informational rows newest-first. "Merged feed" names one visual
25
+ surface, not one timeline.
26
+ - **Only notifications paginate.** Action items are already bounded and are
27
+ standing state, not a timeline. Page 1 = all action items + the first page of
28
+ notifications; every later page is notifications only. Paginating two
29
+ independent sources against one cursor is where merged feeds go wrong.
30
+ - **`unreadCount` is total, never windowed.** A windowed count under-reports the
31
+ moment the inbox exceeds one page, and it drives a badge whose whole job is to
32
+ be trusted.
33
+ - **The cursor encodes `(createdAt, id)`.** The id breaks ties so two rows
34
+ written in one transaction cannot straddle a page boundary and be served twice
35
+ or skipped.
36
+
37
+ ## Public API
38
+
39
+ | Export | Description |
40
+ | --------------------------- | -------------------------------------------------------------- |
41
+ | `UserNotificationSchema` | One informational row, as rendered |
42
+ | `USER_NOTIFICATION_KINDS` | The closed kind vocabulary |
43
+ | `FeedItemSchema` | `actionable` \| `informational`, discriminated on `row` |
44
+ | `FeedListResponseSchema` | `GET /api/me/feed` — items, nextCursor, unreadCount, truncated |
45
+ | `FeedMarkReadRequestSchema` | `POST /api/me/feed/read` |
46
+
47
+ ## Dependencies
48
+
49
+ - `zod` — schemas are canonical, types are inferred.
50
+ - `../action-items` — `ActionItemSchema`, `ActionItemTargetSchema`. The feed
51
+ UNIONS with action items; it does not redefine them, and it does not modify
52
+ them.
53
+
54
+ ## How this differs from the two vocabularies beside it
55
+
56
+ Three things in this package look like "a notification". ADR-CONT-104 forbids
57
+ collapsing the first two; this adds a third rather than bending either:
58
+
59
+ - **`NotificationKind`** (`../notifications`) names a message that **leaves the
60
+ building** — composed into prose, delivered once to an address, lifecycle over
61
+ at send.
62
+ - **`ActionItemKind`** (`../action-items`) names **standing state** — a decision
63
+ you owe, re-derived on every read, gone the moment anyone resolves it. Never
64
+ stored, no read state.
65
+ - **`UserNotificationKind`** names a **stored fact about the past**, addressed to
66
+ one person, that they can only look at. `action-items/kinds.ts` named this gap
67
+ itself: _"a kind whose item cannot be acted on is not an action item — that is
68
+ the informational feed, which this layer does not model (yet)."_
69
+
70
+ They are not in bijection. `companyMd.access_request_pending` is an action item
71
+ and deliberately has **no** member here: the owner can resolve it, so modelling
72
+ it in both places would give one fact two lifecycles that could disagree.
@@ -0,0 +1,43 @@
1
+ # user-notifications/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Locks the claims `../README.md` and ADR-CONT-108 make that the compiler cannot.
6
+
7
+ - `vocabulary.test.ts` — four things a type signature does not say:
8
+ 1. **The three vocabularies stay disjoint.** `UserNotificationKind`,
9
+ `ActionItemKind` and `NotificationKind` overlap in subject matter and look
10
+ mergeable; they are not, because a notification's lifecycle ends at send, an
11
+ action item's ends when anyone resolves it, and one of these ends never.
12
+ Specifically: `companyMd.access_request_pending` must NOT appear here — it
13
+ is resolvable, so modelling it in both places would give one fact two
14
+ lifecycles that could disagree.
15
+ 2. **A tombstone parses.** `href: null` is a legitimate render, not a
16
+ degenerate one: a reader who lost access still learns the thing happened.
17
+ If this stops parsing, revocation silently becomes an error path.
18
+ 3. **`FeedItem` discriminates on `row`, not on field-sniffing.** A client must
19
+ never have to infer "informational" from the presence of `readAt`.
20
+ 4. **The response carries no field named `version`.** The app's
21
+ `invalidateResource` probes cached payloads for that name structurally and
22
+ SKIPS the invalidation when the cached value is at least as new — the same
23
+ trap `action-items/__tests__` guards, and it would wedge the feed badge
24
+ with no error anywhere.
25
+
26
+ ## Invariants
27
+
28
+ - These assert VOCABULARY and SHAPE, never behaviour. Anything needing a
29
+ renderer, a database or an authority gate belongs in backend's
30
+ `src/user-notifications/__tests__/`.
31
+ - The disjointness test reads the real kind arrays rather than restating them. A
32
+ hand-copied list drifts and starts passing vacuously.
33
+ - Negative cases mutate ONE field of a well-formed fixture. A hand-built broken
34
+ object can pass for the wrong reason.
35
+
36
+ ## Public API
37
+
38
+ None — test-only.
39
+
40
+ ## Dependencies
41
+
42
+ `vitest`, the sibling modules under test, and `../../action-items` /
43
+ `../../notifications` for the real kind lists.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The inbox vocabulary's invariants, as tests rather than prose (ADR-CONT-108).
3
+ */
4
+ import { describe, it, expect } from "vitest";
5
+ import { USER_NOTIFICATION_KINDS } from "../kinds.js";
6
+ import {
7
+ FeedItemSchema,
8
+ FeedListResponseSchema,
9
+ UserNotificationSchema,
10
+ } from "../schemas.js";
11
+ import { ACTION_ITEM_KINDS } from "../../action-items/index.js";
12
+ import { NOTIFICATION_DEFINITIONS } from "../../notifications/index.js";
13
+
14
+ const ORG_DOC = "22222222-2222-4222-8222-222222222222";
15
+
16
+ function makeNotification(over: Record<string, unknown> = {}) {
17
+ return {
18
+ id: "n-1",
19
+ kind: "companyMd.access_request_approved",
20
+ target: { type: "company_md", id: ORG_DOC },
21
+ unitPath: "acme.sales",
22
+ title: "Dev Patel approved your request",
23
+ detail: null,
24
+ contextLabel: "Q3 OKRs",
25
+ href: "/@acme/md/doc-1",
26
+ createdAt: "2026-07-30T12:00:00.000Z",
27
+ readAt: null,
28
+ ...over,
29
+ };
30
+ }
31
+
32
+ describe("the three vocabularies stay disjoint", () => {
33
+ it("shares no member with ActionItemKind", () => {
34
+ // They overlap in SUBJECT MATTER and look mergeable. They are not: an
35
+ // action item is standing state that ends when anyone resolves it; one of
36
+ // these is a stored fact that ends never.
37
+ const actionItems = new Set<string>(ACTION_ITEM_KINDS);
38
+ for (const kind of USER_NOTIFICATION_KINDS) {
39
+ expect(actionItems.has(kind)).toBe(false);
40
+ }
41
+ });
42
+
43
+ it("does NOT model the pending request, which is resolvable", () => {
44
+ // The load-bearing case. `companyMd.access_request_pending` is an ACTION
45
+ // ITEM — the owner can settle it. A durable twin would give one fact two
46
+ // lifecycles that could disagree: resolve the action item and the stored
47
+ // row would still claim something is pending.
48
+ expect(USER_NOTIFICATION_KINDS).not.toContain(
49
+ "companyMd.access_request_pending",
50
+ );
51
+ });
52
+
53
+ it("names kinds in {domain}.{type} form, matching both siblings", () => {
54
+ for (const kind of USER_NOTIFICATION_KINDS) {
55
+ expect(kind).toMatch(/^[a-z][a-zA-Z0-9]*\.[a-z][a-z0-9_]*$/);
56
+ }
57
+ });
58
+
59
+ it("overlaps NotificationKind by NAME without inheriting its lifecycle", () => {
60
+ // Reading the real definitions rather than restating them, so this cannot
61
+ // pass vacuously. An overlap here is EXPECTED and fine — the email and the
62
+ // stored row are two different things about one event. What must not happen
63
+ // is one union being derived from the other.
64
+ const sent = new Set(Object.keys(NOTIFICATION_DEFINITIONS));
65
+ const shared = USER_NOTIFICATION_KINDS.filter((k) => sent.has(k));
66
+ expect(shared.length).toBeGreaterThan(0);
67
+ expect(USER_NOTIFICATION_KINDS.length).not.toBe(sent.size);
68
+ });
69
+ });
70
+
71
+ describe("UserNotificationSchema", () => {
72
+ it("parses a well-formed row", () => {
73
+ expect(UserNotificationSchema.safeParse(makeNotification()).success).toBe(
74
+ true,
75
+ );
76
+ });
77
+
78
+ it("parses a TOMBSTONE — href null is a legitimate render", () => {
79
+ // A reader who lost access, or whose client cannot decode the stored
80
+ // ref_version, still learns the thing happened. If this stops parsing,
81
+ // revocation silently becomes an error path instead of a render.
82
+ expect(
83
+ UserNotificationSchema.safeParse(makeNotification({ href: null }))
84
+ .success,
85
+ ).toBe(true);
86
+ });
87
+
88
+ it("carries read state, unlike an action item", () => {
89
+ const read = UserNotificationSchema.parse(
90
+ makeNotification({ readAt: "2026-07-30T13:00:00.000Z" }),
91
+ );
92
+ expect(read.readAt).not.toBeNull();
93
+ });
94
+
95
+ it("rejects an unknown kind", () => {
96
+ expect(
97
+ UserNotificationSchema.safeParse(makeNotification({ kind: "made.up" }))
98
+ .success,
99
+ ).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe("FeedItem discriminates on `row`", () => {
104
+ it("accepts an informational row", () => {
105
+ const parsed = FeedItemSchema.safeParse({
106
+ row: "informational",
107
+ notification: makeNotification(),
108
+ });
109
+ expect(parsed.success).toBe(true);
110
+ });
111
+
112
+ it("accepts an actionable row", () => {
113
+ const parsed = FeedItemSchema.safeParse({
114
+ row: "actionable",
115
+ item: {
116
+ id: "companyMd.access_request_pending:req-1",
117
+ kind: "companyMd.access_request_pending",
118
+ target: { type: "company_md", id: ORG_DOC },
119
+ unitPath: null,
120
+ title: "Maya Chen wants access",
121
+ detail: null,
122
+ contextLabel: "Q3 OKRs",
123
+ href: "/@acme/md/doc-1?share=1&request=req-1",
124
+ createdAt: "2026-07-30T12:00:00.000Z",
125
+ },
126
+ });
127
+ expect(parsed.success).toBe(true);
128
+ });
129
+
130
+ it("cannot be satisfied by field-sniffing — the tag is required", () => {
131
+ // A client must never infer "informational" from the presence of `readAt`.
132
+ expect(
133
+ FeedItemSchema.safeParse({ notification: makeNotification() }).success,
134
+ ).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe("FeedListResponse", () => {
139
+ const base = {
140
+ items: [],
141
+ nextCursor: null,
142
+ unreadCount: 0,
143
+ truncated: false,
144
+ };
145
+
146
+ it("parses an empty feed", () => {
147
+ expect(FeedListResponseSchema.safeParse(base).success).toBe(true);
148
+ });
149
+
150
+ it("carries a cursor for the next page of informational rows", () => {
151
+ expect(
152
+ FeedListResponseSchema.safeParse({ ...base, nextCursor: "opaque" })
153
+ .success,
154
+ ).toBe(true);
155
+ });
156
+
157
+ it("has NO field named `version`", () => {
158
+ // The app's invalidateResource probes cached payloads for that name
159
+ // structurally and SKIPS the invalidation when the cached value is at least
160
+ // as new. A field of that name here would wedge the feed badge with no
161
+ // error anywhere. Same trap `action-items/__tests__` guards.
162
+ expect(Object.keys(FeedListResponseSchema.shape)).not.toContain("version");
163
+ });
164
+
165
+ it("rejects a negative unread count", () => {
166
+ expect(
167
+ FeedListResponseSchema.safeParse({ ...base, unreadCount: -1 }).success,
168
+ ).toBe(false);
169
+ });
170
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * user-notifications/ — the durable in-app inbox, and the merged feed that
3
+ * renders it alongside action items.
4
+ *
5
+ * See ./README.md for the domain, and ADR-CONT-108 for why this is a third
6
+ * vocabulary rather than a bend in either of the two beside it.
7
+ */
8
+
9
+ export { USER_NOTIFICATION_KINDS } from "./kinds";
10
+ export type { UserNotificationKind } from "./kinds";
11
+
12
+ export {
13
+ FeedItemSchema,
14
+ FeedListResponseSchema,
15
+ FeedMarkReadRequestSchema,
16
+ UserNotificationKindSchema,
17
+ UserNotificationSchema,
18
+ } from "./schemas";
19
+
20
+ export type {
21
+ FeedItem,
22
+ FeedListResponse,
23
+ FeedMarkReadRequest,
24
+ UserNotification,
25
+ } from "./schemas";
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What kinds of durable in-app notification exist.
3
+ *
4
+ * A user notification is an INFORMATIONAL row addressed to one user: something
5
+ * that happened which they should know about and cannot act on. It is stored,
6
+ * it has read state, and it survives the user being offline.
7
+ *
8
+ * WHY THIS IS A THIRD VOCABULARY, alongside `NotificationKind` and
9
+ * `ActionItemKind` (ADR-CONT-104 forbids collapsing those two; this adds a
10
+ * third rather than bending either):
11
+ *
12
+ * - `NotificationKind` names a message that LEAVES THE BUILDING — an email or a
13
+ * Slack post, composed by a render pipeline into prose carrying a brand and a
14
+ * year, delivered once to an address. Its lifecycle ends at send.
15
+ * - `ActionItemKind` names STANDING STATE — a decision you owe, re-derived from
16
+ * its domain's pending rows on every read, which stops existing the moment
17
+ * anyone resolves it. It is never stored and has no read state, because
18
+ * "resolved" is the only clearing mechanism it needs.
19
+ * - A user notification is neither. It is a FACT about the past, addressed to
20
+ * one person, which they can only look at. `action-items/kinds.ts` already
21
+ * named this gap: "a kind whose item cannot be acted on is not an action item
22
+ * — that is the informational feed, which this layer does not model (yet)."
23
+ * This is that layer.
24
+ *
25
+ * The three are not in bijection and never will be. `auth.otp` is a
26
+ * notification and can be neither of the others. `execution.confirmation_pending`
27
+ * is an action item with no notification. `companyMd.access_request_approved` is
28
+ * all three shapes' subject matter and gets a member in each, because the email,
29
+ * the (now absent) pending decision, and the durable "you were granted access"
30
+ * row are three different things with three different lifecycles.
31
+ *
32
+ * INVARIANTS:
33
+ * - `{domain}.{type}` dot notation, matching both sibling unions.
34
+ * - These strings go on the wire AND into a database column. Renaming one is a
35
+ * migration, not a tidy-up.
36
+ * - A kind earns membership by being something a user should be able to come
37
+ * back to. If it is only worth seeing while connected, it is a transient
38
+ * frame on the user-event stream, not a row here.
39
+ * - A kind's row stores IDENTIFIERS ONLY (see `./schemas`). Copy lives in the
40
+ * producing domain's renderer and is resolved at read time.
41
+ */
42
+
43
+ // =============================================================================
44
+ // UserNotificationKind Union
45
+ // =============================================================================
46
+
47
+ /**
48
+ * New kinds MUST be added to:
49
+ * 1. This union
50
+ * 2. A `NotificationRenderer` in the PRODUCING domain, which applies that
51
+ * domain's own authority gate in bulk and returns a tombstone when the
52
+ * reader may no longer see the referenced entity
53
+ *
54
+ * The three members below are the informational half of the company.md
55
+ * request-access lifecycle. Note what is NOT here:
56
+ * `companyMd.access_request_pending` is an ACTION ITEM — the owner can resolve
57
+ * it, so it is standing state, and modelling it here as well would give one
58
+ * fact two lifecycles that could disagree.
59
+ */
60
+ export const USER_NOTIFICATION_KINDS = [
61
+ /** Someone asked for access to a doc you own. Informational twin of the action item. */
62
+ "companyMd.access_requested",
63
+ /** Your request was approved — you now have access. */
64
+ "companyMd.access_request_approved",
65
+ /** Your request was declined. */
66
+ "companyMd.access_request_denied",
67
+ ] as const;
68
+
69
+ export type UserNotificationKind = (typeof USER_NOTIFICATION_KINDS)[number];
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The published shapes of the durable notification inbox and the merged feed
3
+ * that renders it (`GET /api/me/feed`).
4
+ *
5
+ * Zod-canonical: schema is the source of truth, the type is inferred.
6
+ */
7
+ import { z } from "zod";
8
+ import { ActionItemSchema, ActionItemTargetSchema } from "../action-items";
9
+ import { USER_NOTIFICATION_KINDS } from "./kinds";
10
+
11
+ /** Zod mirror of the vocabulary. `./kinds` owns the list; this never restates it. */
12
+ export const UserNotificationKindSchema = z.enum(USER_NOTIFICATION_KINDS);
13
+
14
+ /**
15
+ * One informational row, AS RENDERED — never as stored.
16
+ *
17
+ * The stored row holds identifiers only (`kind` + a `ref` of ids). Everything
18
+ * below that reads like prose — `title`, `detail`, `contextLabel`, `href` — is
19
+ * produced at READ TIME by the producing domain's renderer, under the reader's
20
+ * CURRENT authority. Three problems dissolve at once:
21
+ *
22
+ * - **Copy evolution.** There is no frozen prose in the database to decode, so
23
+ * changing wording is a renderer change, not a migration.
24
+ * - **Authorization drift.** A user who lost access to a document after being
25
+ * notified about it must not keep reading its title out of an old row. The
26
+ * renderer re-checks and returns a tombstone instead.
27
+ * - **Deletion.** A notification about a deleted doc renders as a tombstone
28
+ * rather than dangling.
29
+ *
30
+ * The invariant that makes this safe is on the storage side and is worth
31
+ * stating here too, because this is where someone would be tempted to break it:
32
+ * **a stored notification row never contains protected content**, so a stale row
33
+ * cannot leak one.
34
+ *
35
+ * Deliberately shaped PARALLEL to `ActionItemSchema` — same field names, same
36
+ * meanings — so the merged feed is a union over two near-identical rows and the
37
+ * existing roll-up helpers keep working unchanged.
38
+ */
39
+ export const UserNotificationSchema = z.object({
40
+ /** The stored row's id. Unlike an action item's, this IS a row id. */
41
+ id: z.string(),
42
+ kind: UserNotificationKindSchema,
43
+ target: ActionItemTargetSchema,
44
+ /** ltree anchor for subtree roll-up; `null` = org-wide. */
45
+ unitPath: z.string().nullable(),
46
+ /** One line naming what happened, e.g. "Dev Patel approved your request". */
47
+ title: z.string(),
48
+ /** Optional second line — the decider's note, a reason. */
49
+ detail: z.string().nullable(),
50
+ /** The thing it is about, as the user names it, e.g. "Sales.md". */
51
+ contextLabel: z.string(),
52
+ /**
53
+ * Where to go to SEE it — a RELATIVE app path.
54
+ *
55
+ * `null` when the reader can no longer reach the referent (access revoked,
56
+ * doc deleted, or a `ref` this client's version cannot decode). A tombstoned
57
+ * row still renders, because "you were granted access to something you can no
58
+ * longer see" is itself true and useful; it simply does not link anywhere.
59
+ */
60
+ href: z.string().nullable(),
61
+ createdAt: z.string(),
62
+ /** `null` while unread. The only mutable field on the row. */
63
+ readAt: z.string().nullable(),
64
+ });
65
+ export type UserNotification = z.infer<typeof UserNotificationSchema>;
66
+
67
+ // =============================================================================
68
+ // The merged feed
69
+ // =============================================================================
70
+
71
+ /**
72
+ * One row of `/me/work`'s top section, discriminated by what it DEMANDS.
73
+ *
74
+ * `actionable` rows are derived standing state you can resolve; `informational`
75
+ * rows are stored facts you can only read. They are merged for PRESENTATION —
76
+ * one list under one heading — and nowhere else: the two producing vocabularies
77
+ * stay separate (ADR-CONT-104), and the `action-items` domain still owns no
78
+ * tables.
79
+ *
80
+ * Discriminating on `row` rather than sniffing for `readAt` keeps the client
81
+ * from having to know which fields imply which lifecycle.
82
+ */
83
+ export const FeedItemSchema = z.discriminatedUnion("row", [
84
+ z.object({ row: z.literal("actionable"), item: ActionItemSchema }),
85
+ z.object({
86
+ row: z.literal("informational"),
87
+ notification: UserNotificationSchema,
88
+ }),
89
+ ]);
90
+ export type FeedItem = z.infer<typeof FeedItemSchema>;
91
+
92
+ /**
93
+ * `GET /api/me/feed`.
94
+ *
95
+ * ORDERING IS INTENTIONALLY GROUPED, NOT CHRONOLOGICALLY INTERLEAVED: every
96
+ * `actionable` row first, then `informational` rows newest-first. "Merged feed"
97
+ * names one visual surface, not one timeline. Grouping is what keeps "what do I
98
+ * owe?" answerable at a glance instead of scattered between announcements — and
99
+ * it is what makes the pagination rule below coherent.
100
+ *
101
+ * ONLY NOTIFICATIONS PAGINATE. Action items are already bounded by
102
+ * `ACTION_ITEM_LIMIT` and are standing state rather than a timeline, so page 1
103
+ * carries all of them plus the first page of notifications, and every
104
+ * subsequent page is notifications only. Paginating two independent sources
105
+ * against one cursor is where merged feeds go wrong.
106
+ */
107
+ export const FeedListResponseSchema = z.object({
108
+ items: z.array(FeedItemSchema),
109
+ /**
110
+ * Opaque cursor for the NEXT page of informational rows; `null` at the end.
111
+ *
112
+ * Encodes `(createdAt, id)` — `id` breaks ties so two rows written in the same
113
+ * transaction cannot straddle a page boundary and be served twice or skipped.
114
+ */
115
+ nextCursor: z.string().nullable(),
116
+ /**
117
+ * Total unread across ALL retained notifications — NOT within the returned
118
+ * window.
119
+ *
120
+ * A windowed count would silently under-report the moment the inbox grew past
121
+ * one page, and this number drives a badge whose entire job is to be trusted.
122
+ */
123
+ unreadCount: z.number().int().nonnegative(),
124
+ /** True when the ACTIONABLE half hit its cap; notifications say so via `nextCursor`. */
125
+ truncated: z.boolean(),
126
+ });
127
+ export type FeedListResponse = z.infer<typeof FeedListResponseSchema>;
128
+
129
+ /** `POST /api/me/feed/read` — mark specific informational rows read. */
130
+ export const FeedMarkReadRequestSchema = z.object({
131
+ notificationIds: z.array(z.string()).min(1).max(200),
132
+ });
133
+ export type FeedMarkReadRequest = z.infer<typeof FeedMarkReadRequestSchema>;