@company-semantics/contracts 82.0.0 → 83.0.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": "82.0.0",
3
+ "version": "83.0.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 = '2e37dedd5ad7' as const;
3
- export const SPEC_HASH_FULL = '2e37dedd5ad79e5b8ea6f6b470525a074c62c4a1bdf642b4792db0f893af887f' as const;
2
+ export const SPEC_HASH = '9286d0cf0c7c' as const;
3
+ export const SPEC_HASH_FULL = '9286d0cf0c7c1457a11e830402e9e860ee80e5b0b263b79d4ec15308d7ecb271' as const;
@@ -7629,9 +7629,13 @@ export interface components {
7629
7629
  href: string | null;
7630
7630
  createdAt: string;
7631
7631
  readAt: string | null;
7632
+ activityAt: string;
7633
+ occurrenceCount: number;
7634
+ resolvedAt: string | null;
7632
7635
  };
7633
7636
  })[];
7634
7637
  nextCursor: string | null;
7638
+ viewCount: number;
7635
7639
  unreadCount: number;
7636
7640
  truncated: boolean;
7637
7641
  };
package/src/index.ts CHANGED
@@ -327,7 +327,8 @@ export type {
327
327
  UserSseEvent,
328
328
  } from "./user-events/index";
329
329
 
330
- // Durable in-app inbox + the merged feed that renders it (ADR-CONT-108). The
330
+ // Durable in-app inbox + the merged feed that renders it
331
+ // (user-notification-inbox-and-merged-feed). The
331
332
  // THIRD vocabulary beside NotificationKind and ActionItemKind: a notification
332
333
  // leaves the building, an action item is standing state you can resolve, and
333
334
  // one of these is a stored fact you can only look at. Rows hold identifiers
@@ -352,6 +353,55 @@ export type {
352
353
  UserNotificationKind,
353
354
  } from "./user-notifications/index";
354
355
 
356
+ // Each inbox kind's declared lifecycle — condition, supersession, resolution,
357
+ // retention (notification-lifecycle-is-declared-per-kind) — and the one
358
+ // function that turns a kind + domain ref into its condition/supersession keys.
359
+ export {
360
+ conditionKeyOf,
361
+ NOTIFICATION_ATTENTION_ON_SUPERSEDE,
362
+ NOTIFICATION_LIFECYCLE,
363
+ NOTIFICATION_RESOLUTION_AUDIENCES,
364
+ NOTIFICATION_RETENTION_CLASSES,
365
+ NotificationConditionKeyError,
366
+ notificationKeysOf,
367
+ } from "./user-notifications/index";
368
+
369
+ export type {
370
+ NotificationAttentionOnSupersede,
371
+ NotificationCondition,
372
+ NotificationKeys,
373
+ NotificationLifecycle,
374
+ NotificationResolution,
375
+ NotificationResolutionAudience,
376
+ NotificationRetention,
377
+ } from "./user-notifications/index";
378
+
379
+ // The feed's views, selectors (explicit ids, or a view as of `asOf`) and verb
380
+ // wire — read / unread / delete / restore — for the caller's inbox rows.
381
+ export {
382
+ FEED_SELECTOR_MAX_IDS,
383
+ FEED_VIEWS,
384
+ FeedDeleteRequestSchema,
385
+ FeedDeleteResponseSchema,
386
+ FeedMarkUnreadRequestSchema,
387
+ FeedMutationResponseSchema,
388
+ FeedRestoreRequestSchema,
389
+ FeedRestoreResponseSchema,
390
+ FeedSelectorSchema,
391
+ FeedViewSchema,
392
+ } from "./user-notifications/index";
393
+
394
+ export type {
395
+ FeedDeleteRequest,
396
+ FeedDeleteResponse,
397
+ FeedMarkUnreadRequest,
398
+ FeedMutationResponse,
399
+ FeedRestoreRequest,
400
+ FeedRestoreResponse,
401
+ FeedSelector,
402
+ FeedView,
403
+ } from "./user-notifications/index";
404
+
355
405
  // Proactive delivery — how the system speaks first (ADR-CONTRACTS-142). A
356
406
  // COMPOSER above the three notification vocabularies, not a fourth peer: the
357
407
  // presentation class is a TOTAL FUNCTION from class to surfaces
@@ -21,8 +21,8 @@ that renders them alongside action items at the top of `/me/work`.
21
21
  sees that the thing happened — it simply does not link anywhere. "You were
22
22
  granted access to something you can no longer see" is true and worth saying.
23
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.
24
+ first, then informational rows unread-and-unresolved first, then `activityAt`
25
+ DESC, `id` DESC. "Merged feed" names one visual surface, not one timeline.
26
26
  - **Only notifications paginate.** Action items are already bounded and are
27
27
  standing state, not a timeline. Page 1 = all action items + the first page of
28
28
  notifications; every later page is notifications only. Paginating two
@@ -30,9 +30,9 @@ that renders them alongside action items at the top of `/me/work`.
30
30
  - **`unreadCount` is total, never windowed.** A windowed count under-reports the
31
31
  moment the inbox exceeds one page, and it drives a badge whose whole job is to
32
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.
33
+ - **The cursor is opaque and encodes `(unread, activityAt, id)`**, mirroring the
34
+ ordering. The id breaks ties so two rows with the same activity cannot
35
+ straddle a page boundary and be served twice or skipped.
36
36
  - **A row can be BORN READ.** Every proactive kind writes a durable row here
37
37
  as its paper trail, but only the class whose badge carrier is `inbox` writes
38
38
  it unread; `proactive.org_became_shared` is an `announcement`, so the banner
@@ -83,15 +83,102 @@ that renders them alongside action items at the top of `/me/work`.
83
83
  anywhere, and the proactive `unread` flag on a chat summary stays a one-time
84
84
  arrival badge that ordinary conversation never re-lights.
85
85
 
86
+ ## Lifecycle
87
+
88
+ A row is the **current attention item for one condition**, not an event record
89
+ (control ADR `notification-inbox-is-an-attention-surface`; contracts ADR
90
+ `notification-lifecycle-is-declared-per-kind`). Every kind declares its
91
+ lifecycle in `NOTIFICATION_LIFECYCLE`, typed
92
+ `Readonly<Record<UserNotificationKind, NotificationLifecycle>>`, so a kind
93
+ without one — or an entry for a kind that does not exist — fails to compile.
94
+ Each entry declares four things:
95
+
96
+ - **Condition — what the row is about.** `none` (an independent delivery with no
97
+ identity), `{ scope: 'kind' }` (one condition per kind), or
98
+ `{ scope: 'fields', fields }` (named fields of the producing domain's ref). The
99
+ condition is the identity RESOLUTION addresses.
100
+ - **Supersession — a property of the condition, never a second identity.**
101
+ `supersedes: true` means a repeat occurrence updates the active row for that
102
+ condition instead of appending another; `supersedes: false` means each
103
+ occurrence is its own row (access requests: every request stands on its own).
104
+ A `none` condition cannot supersede.
105
+ - **Renew vs preserve.** A superseding kind declares `attentionOnSupersede`.
106
+ `renew` restores the kind's normal initial attention state — unread, or read
107
+ when its proactive class is born read (`bornRead` decides, not this registry);
108
+ the repeat is news again. `preserve` keeps the row's current read state; the
109
+ repeat is a quiet update to a standing condition. Only a superseding kind may
110
+ declare it.
111
+ - **Resolution, with an explicit audience.** `never`, or
112
+ `{ mode: 'source', audience }`: the producing DOMAIN resolves the condition,
113
+ for `all-recipients` or only the `recipient`. Source resolution needs a
114
+ condition to address, so `none` + `source` does not compile.
115
+ - **Retention class.** `standard` or `persistent`. Every kind is `standard`
116
+ today; `persistent` must be declared, never accidental. The class is all
117
+ contracts carries — the retention DAY values live in the backend.
118
+
119
+ The union makes the three illegal combinations unrepresentable (source
120
+ resolution without a condition, `attentionOnSupersede` without
121
+ `supersedes: true`, supersession of `none`); `__tests__/compile-fixtures.ts`
122
+ pins each with `@ts-expect-error`.
123
+
124
+ **One key builder.** `conditionKeyOf(kind, domainRef)` is the only way a
125
+ condition key is spelled: the kind itself for `scope: 'kind'`; for
126
+ `scope: 'fields'`, the kind then each named field sorted by name as
127
+ `name=encodeURIComponent(value)`, joined by `|`; `null` for `none`. Values are
128
+ never trimmed or lowercased, and a named field that is absent, not a string, or
129
+ empty throws `NotificationConditionKeyError`. `notificationKeysOf` returns
130
+ `{ conditionKey, supersessionKey }`, where the supersession key is the condition
131
+ key of a superseding kind and `null` otherwise — not a second identity.
132
+
133
+ **The row carries its lifecycle.** `activityAt` is the time of the latest
134
+ occurrence (the ordering and retention clock), `occurrenceCount` how many
135
+ occurrences the row has absorbed by supersession, and `resolvedAt` when the
136
+ domain resolved the condition (`null` while it stands). All three are required.
137
+
138
+ **The verbs take a selector** (`feed-lifecycle.ts`): explicit `ids` (at most
139
+ `FEED_SELECTOR_MAX_IDS`), or a `view` (`all` | `unread`) AS OF `asOf`, so rows
140
+ that arrive after the person acted are never swept in. `viewCount` on the feed
141
+ response counts the notification rows in the requested view. A delete returns
142
+ an opaque `restoreKey`; a restore reports `{ restored, superseded }`, where
143
+ `superseded` counts rows left deleted because a newer active row already
144
+ represents their condition.
145
+
146
+ **There is no Archive.** No archived state, no archived view, no archive verb.
147
+ A row is current, resolved, or deleted — and a deleted row is never served.
148
+
149
+ The per-kind table and the retention and Undo values live in
150
+ company-semantics-control docs/notification-inbox.md; the registry mirrors that
151
+ table entry for entry.
152
+
86
153
  ## Public API
87
154
 
88
- | Export | Description |
89
- | --------------------------- | -------------------------------------------------------------- |
90
- | `UserNotificationSchema` | One informational row, as rendered |
91
- | `USER_NOTIFICATION_KINDS` | The closed kind vocabulary |
92
- | `FeedItemSchema` | `actionable` \| `informational`, discriminated on `row` |
93
- | `FeedListResponseSchema` | `GET /api/me/feed` — items, nextCursor, unreadCount, truncated |
94
- | `FeedMarkReadRequestSchema` | `POST /api/me/feed/read` |
155
+ | Export | Description |
156
+ | ------------------------------------- | ------------------------------------------------------------------------- |
157
+ | `UserNotificationSchema` | One informational row, as rendered |
158
+ | `USER_NOTIFICATION_KINDS` | The closed kind vocabulary |
159
+ | `FeedItemSchema` | `actionable` \| `informational`, discriminated on `row` |
160
+ | `FeedListResponseSchema` | `GET /api/me/feed` — items, nextCursor, viewCount, unreadCount, truncated |
161
+ | `FeedMarkReadRequestSchema` | `POST /api/me/feed/read` — `{ selector }` |
162
+ | `FEED_VIEWS` / `FeedViewSchema` | `all` \| `unread` |
163
+ | `FeedSelectorSchema` | `{ kind: 'ids', notificationIds }` \| `{ kind: 'view', view, asOf }` |
164
+ | `FEED_SELECTOR_MAX_IDS` | The most ids one explicit selection may name (200) |
165
+ | `FeedMarkUnreadRequestSchema` | Mark unread — `{ selector }` |
166
+ | `FeedDeleteRequestSchema` | Delete — `{ selector }` |
167
+ | `FeedRestoreRequestSchema` | Undo a delete — `{ restoreKey }` |
168
+ | `FeedMutationResponseSchema` | `{ affected }` |
169
+ | `FeedDeleteResponseSchema` | `{ affected, restoreKey }` — the key is opaque |
170
+ | `FeedRestoreResponseSchema` | `{ restored, superseded }` |
171
+ | `NOTIFICATION_LIFECYCLE` | Every kind's declared lifecycle |
172
+ | `NotificationLifecycle` | The lifecycle union (condition, supersession, resolution, …) |
173
+ | `NotificationCondition` | `{ scope: 'kind' }` \| `{ scope: 'fields', fields }` |
174
+ | `NotificationResolution` | `never` \| `{ mode: 'source', audience }` |
175
+ | `NOTIFICATION_ATTENTION_ON_SUPERSEDE` | `renew` \| `preserve` |
176
+ | `NOTIFICATION_RESOLUTION_AUDIENCES` | `all-recipients` \| `recipient` |
177
+ | `NOTIFICATION_RETENTION_CLASSES` | `standard` \| `persistent` |
178
+ | `conditionKeyOf` | The one condition-key builder (kind + domain ref → key) |
179
+ | `notificationKeysOf` | `{ conditionKey, supersessionKey }` for a kind + domain ref |
180
+ | `NotificationKeys` | The pair `notificationKeysOf` returns |
181
+ | `NotificationConditionKeyError` | A named condition field is absent, not a string, or empty |
95
182
 
96
183
  ## Dependencies
97
184
 
@@ -23,6 +23,25 @@ Locks the claims `../README.md` and ADR-CONT-108 make that the compiler cannot.
23
23
  trap `action-items/__tests__` guards, and it would wedge the feed badge
24
24
  with no error anywhere.
25
25
 
26
+ - `lifecycle.test.ts` — the per-kind lifecycle registry: every kind in the
27
+ real `USER_NOTIFICATION_KINDS` declares one, a source-resolved kind always has
28
+ a condition, access requests never supersede, and no kind is `persistent`
29
+ yet.
30
+ - `condition-keys.test.ts` — the one key builder's exact format: fields sorted
31
+ by name and URI-encoded (never trimmed or lowercased), a missing or empty
32
+ field throws, an independent kind has no key, a non-superseding kind has no
33
+ supersession key, and ref fields the lifecycle does not name never change it.
34
+ - `feed-lifecycle.test.ts` — the feed wire: an `ids` selector names 1..200
35
+ ids, a `view` selector needs its `asOf` snapshot, there is no `archived`
36
+ view, mark-read takes a selector, a delete returns a restore key and a
37
+ restore counts superseded rows, and the row's `activityAt`,
38
+ `occurrenceCount` and `resolvedAt` (and the response's `viewCount`) are
39
+ required.
40
+ - `compile-fixtures.ts` — not a test file, so `tsc -b` compiles it: the three
41
+ illegal lifecycles, each pinned with `@ts-expect-error`. A directive written
42
+ in a `*.test.ts` is never typechecked; one here fails `pnpm typecheck` with
43
+ TS2578 the moment the union loosens.
44
+
26
45
  ## Invariants
27
46
 
28
47
  - These assert VOCABULARY and SHAPE, never behavior. Anything needing a
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Negative typecheck fixtures — the illegal lifecycles the per-kind registry
3
+ * (notification-lifecycle-is-declared-per-kind) refuses, each pinned with
4
+ * `@ts-expect-error`.
5
+ *
6
+ * The directive is the proof. If the `NotificationLifecycle` union ever loosens
7
+ * so one of these becomes representable, the directive goes UNUSED and
8
+ * `pnpm typecheck` fails with TS2578.
9
+ *
10
+ * WHY A COMPILED MODULE. `src/tsconfig.json` excludes every `*.test.ts` file
11
+ * and vitest does not typecheck, so a directive inside `lifecycle.test.ts` is
12
+ * never checked. This file is not a `*.test.ts`, so `tsc -b` compiles it (the
13
+ * same placement as `../../proactive/__tests__/compile-fixtures.ts`), and
14
+ * `lifecycle.test.ts` imports it.
15
+ *
16
+ * Each directive sits IMMEDIATELY above the line the compiler reports on.
17
+ */
18
+
19
+ import type { NotificationLifecycle } from "../lifecycle";
20
+
21
+ /** CLAIM 1 — source resolution needs a condition identity to resolve. */
22
+ const sourceWithoutCondition: NotificationLifecycle = {
23
+ condition: "none",
24
+ // @ts-expect-error a condition-less kind resolves `never`
25
+ resolution: { mode: "source", audience: "recipient" },
26
+ retention: "standard",
27
+ };
28
+
29
+ /** CLAIM 2 — attention on supersede means nothing without supersession. */
30
+ const attentionWithoutSupersession: NotificationLifecycle = {
31
+ condition: { scope: "kind" },
32
+ supersedes: false,
33
+ // @ts-expect-error `attentionOnSupersede` exists only when `supersedes: true`
34
+ attentionOnSupersede: "renew",
35
+ resolution: "never",
36
+ retention: "standard",
37
+ };
38
+
39
+ /** CLAIM 3 — a condition-less kind has nothing to supersede by. */
40
+ const supersedingWithoutCondition: NotificationLifecycle = {
41
+ condition: "none",
42
+ // @ts-expect-error a `none` condition carries no `supersedes`
43
+ supersedes: true,
44
+ attentionOnSupersede: "renew",
45
+ resolution: "never",
46
+ retention: "standard",
47
+ };
48
+
49
+ /** The fixtures, exported so the directives are not dead code. */
50
+ export const ILLEGAL_LIFECYCLES = [
51
+ sourceWithoutCondition,
52
+ attentionWithoutSupersession,
53
+ supersedingWithoutCondition,
54
+ ] as const;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The one condition-key builder (notification-lifecycle-is-declared-per-kind).
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - A `fields` key is the kind, then each named field sorted by name as
6
+ * name=encodeURIComponent(value), joined by `|` — whatever order the ref
7
+ * lists them in.
8
+ * - A named field that is absent, not a string, or empty throws
9
+ * NotificationConditionKeyError naming the kind and the field.
10
+ * - An independent kind has neither key; a non-superseding kind has a
11
+ * condition key but no supersession key; a `kind` key is the kind itself.
12
+ * - Values are never trimmed or lowercased, and fields the lifecycle does not
13
+ * name never change the key.
14
+ * - Every export is reachable from both the domain barrel and the root barrel.
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+ import * as root from "../../index";
18
+ import * as domain from "../index";
19
+ import {
20
+ conditionKeyOf,
21
+ NotificationConditionKeyError,
22
+ notificationKeysOf,
23
+ } from "../condition-keys";
24
+
25
+ describe("conditionKeyOf / notificationKeysOf", () => {
26
+ it("condition key orders fields by name", () => {
27
+ const a = conditionKeyOf("comment.reply", {
28
+ subjectType: "company_md",
29
+ subjectId: "doc-1",
30
+ });
31
+ const b = conditionKeyOf("comment.reply", {
32
+ subjectId: "doc-1",
33
+ subjectType: "company_md",
34
+ });
35
+ expect(a).toBe("comment.reply|subjectId=doc-1|subjectType=company_md");
36
+ expect(b).toBe(a);
37
+ });
38
+
39
+ it("condition key refuses a missing field", () => {
40
+ const build = () =>
41
+ conditionKeyOf("companyMd.access_requested", { docId: "doc-1" });
42
+ expect(build).toThrow(NotificationConditionKeyError);
43
+ try {
44
+ build();
45
+ } catch (error) {
46
+ expect(error).toMatchObject({
47
+ name: "NotificationConditionKeyError",
48
+ kind: "companyMd.access_requested",
49
+ field: "requestId",
50
+ });
51
+ }
52
+ });
53
+
54
+ it("condition key refuses an empty or non-string field", () => {
55
+ for (const requestId of ["", 42, null]) {
56
+ expect(() =>
57
+ conditionKeyOf("companyMd.access_requested", { requestId }),
58
+ ).toThrow(NotificationConditionKeyError);
59
+ }
60
+ });
61
+
62
+ it("an independent kind has no condition key", () => {
63
+ expect(
64
+ notificationKeysOf("companyMd.access_request_approved", {
65
+ requestId: "req-1",
66
+ }),
67
+ ).toEqual({ conditionKey: null, supersessionKey: null });
68
+ });
69
+
70
+ it("a non-superseding kind has a condition key but no supersession key", () => {
71
+ expect(
72
+ notificationKeysOf("companyMd.access_requested", { requestId: "req-1" }),
73
+ ).toEqual({
74
+ conditionKey: "companyMd.access_requested|requestId=req-1",
75
+ supersessionKey: null,
76
+ });
77
+ });
78
+
79
+ it("a superseding kind's supersession key is its condition key", () => {
80
+ const keys = notificationKeysOf("chat.mention", { chatId: "chat-1" });
81
+ expect(keys.conditionKey).toBe("chat.mention|chatId=chat-1");
82
+ expect(keys.supersessionKey).toBe(keys.conditionKey);
83
+ });
84
+
85
+ it("a per-kind condition key is the kind itself", () => {
86
+ expect(
87
+ notificationKeysOf("proactive.brief_morning", {
88
+ orgLocalDate: "2026-09-25",
89
+ }),
90
+ ).toEqual({
91
+ conditionKey: "proactive.brief_morning",
92
+ supersessionKey: "proactive.brief_morning",
93
+ });
94
+ });
95
+
96
+ it("extra ref fields never change the key", () => {
97
+ const candidates = conditionKeyOf("proactive.source_sync_review_ready", {
98
+ checkpoint: "candidates",
99
+ runId: "run-1",
100
+ });
101
+ const again = conditionKeyOf("proactive.source_sync_review_ready", {
102
+ checkpoint: "candidates",
103
+ batchId: "batch-9",
104
+ });
105
+ expect(candidates).toBe(
106
+ "proactive.source_sync_review_ready|checkpoint=candidates",
107
+ );
108
+ expect(again).toBe(candidates);
109
+ });
110
+
111
+ it("values are URI-encoded, never trimmed or lowercased", () => {
112
+ expect(conditionKeyOf("chat.mention", { chatId: " Chat|A=B " })).toBe(
113
+ `chat.mention|chatId=${encodeURIComponent(" Chat|A=B ")}`,
114
+ );
115
+ });
116
+
117
+ it("every condition-key export is reachable from both barrels", () => {
118
+ for (const barrel of [root, domain]) {
119
+ expect(barrel.conditionKeyOf).toBe(conditionKeyOf);
120
+ expect(barrel.notificationKeysOf).toBe(notificationKeysOf);
121
+ expect(barrel.NotificationConditionKeyError).toBe(
122
+ NotificationConditionKeyError,
123
+ );
124
+ }
125
+ });
126
+ });
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The feed's views, selectors and verb wire, and the inbox row's lifecycle
3
+ * fields (notification-lifecycle-is-declared-per-kind).
4
+ *
5
+ * INVARIANTS TESTED:
6
+ * - An `ids` selector names 1..FEED_SELECTOR_MAX_IDS ids.
7
+ * - A `view` selector carries an `asOf` snapshot instant; without one it fails.
8
+ * - The views are exactly `all` and `unread` — there is no `archived` view.
9
+ * - A delete returns an opaque restore key; a restore reports the rows it
10
+ * restored and the rows a newer active row had superseded.
11
+ * - `activityAt`, `occurrenceCount` and `resolvedAt` are REQUIRED on a row, and
12
+ * `viewCount` on the feed response.
13
+ * - Mark-read takes a selector, never a bare id list.
14
+ * - Every export is reachable from both the domain barrel and the root barrel.
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+ import * as root from "../../index";
18
+ import * as domain from "../index";
19
+ import {
20
+ FEED_SELECTOR_MAX_IDS,
21
+ FEED_VIEWS,
22
+ FeedDeleteRequestSchema,
23
+ FeedDeleteResponseSchema,
24
+ FeedMarkUnreadRequestSchema,
25
+ FeedMutationResponseSchema,
26
+ FeedRestoreRequestSchema,
27
+ FeedRestoreResponseSchema,
28
+ FeedSelectorSchema,
29
+ FeedViewSchema,
30
+ } from "../feed-lifecycle";
31
+ import {
32
+ FeedListResponseSchema,
33
+ FeedMarkReadRequestSchema,
34
+ UserNotificationSchema,
35
+ } from "../schemas";
36
+
37
+ const AS_OF = "2026-07-30T12:00:00.000Z";
38
+
39
+ function ids(count: number): string[] {
40
+ return Array.from({ length: count }, (_, i) => `n-${i}`);
41
+ }
42
+
43
+ function makeNotification(over: Record<string, unknown> = {}) {
44
+ return {
45
+ id: "n-1",
46
+ kind: "proactive.brief_morning",
47
+ target: { type: "org", id: "22222222-2222-4222-8222-222222222222" },
48
+ unitPath: null,
49
+ title: "Your morning brief is ready",
50
+ detail: null,
51
+ contextLabel: "Morning brief",
52
+ provider: null,
53
+ href: "/brief",
54
+ createdAt: "2026-07-28T08:00:00.000Z",
55
+ readAt: null,
56
+ activityAt: "2026-07-30T08:00:00.000Z",
57
+ occurrenceCount: 3,
58
+ resolvedAt: null,
59
+ ...over,
60
+ };
61
+ }
62
+
63
+ describe("FeedSelector", () => {
64
+ it("accepts exactly the maximum number of ids", () => {
65
+ expect(FEED_SELECTOR_MAX_IDS).toBe(200);
66
+ expect(
67
+ FeedSelectorSchema.safeParse({
68
+ kind: "ids",
69
+ notificationIds: ids(FEED_SELECTOR_MAX_IDS),
70
+ }).success,
71
+ ).toBe(true);
72
+ });
73
+
74
+ it("an ids selector refuses more than 200 ids", () => {
75
+ expect(
76
+ FeedSelectorSchema.safeParse({ kind: "ids", notificationIds: ids(201) })
77
+ .success,
78
+ ).toBe(false);
79
+ });
80
+
81
+ it("an ids selector refuses an empty selection", () => {
82
+ expect(
83
+ FeedSelectorSchema.safeParse({ kind: "ids", notificationIds: [] })
84
+ .success,
85
+ ).toBe(false);
86
+ });
87
+
88
+ it("a view selector requires asOf", () => {
89
+ // Without the snapshot, "everything in this view" would sweep in rows that
90
+ // arrived after the person acted.
91
+ expect(
92
+ FeedSelectorSchema.safeParse({ kind: "view", view: "unread" }).success,
93
+ ).toBe(false);
94
+ expect(
95
+ FeedSelectorSchema.safeParse({ kind: "view", view: "unread", asOf: "" })
96
+ .success,
97
+ ).toBe(false);
98
+ expect(
99
+ FeedSelectorSchema.safeParse({
100
+ kind: "view",
101
+ view: "unread",
102
+ asOf: AS_OF,
103
+ }).success,
104
+ ).toBe(true);
105
+ });
106
+
107
+ it("an unknown view is rejected", () => {
108
+ // There is no Archive — and so no archived view.
109
+ expect(FEED_VIEWS).toEqual(["all", "unread"]);
110
+ expect(FeedViewSchema.safeParse("archived").success).toBe(false);
111
+ expect(
112
+ FeedSelectorSchema.safeParse({
113
+ kind: "view",
114
+ view: "archived",
115
+ asOf: AS_OF,
116
+ }).success,
117
+ ).toBe(false);
118
+ });
119
+
120
+ it("refuses a selector without its discriminant", () => {
121
+ expect(
122
+ FeedSelectorSchema.safeParse({ notificationIds: ids(1) }).success,
123
+ ).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe("the verbs take a selector", () => {
128
+ const selector = { kind: "ids", notificationIds: ids(2) };
129
+
130
+ it("mark read, mark unread and delete all accept { selector }", () => {
131
+ for (const schema of [
132
+ FeedMarkReadRequestSchema,
133
+ FeedMarkUnreadRequestSchema,
134
+ FeedDeleteRequestSchema,
135
+ ]) {
136
+ expect(schema.safeParse({ selector }).success).toBe(true);
137
+ }
138
+ });
139
+
140
+ it("mark read no longer accepts a bare id list", () => {
141
+ expect(
142
+ FeedMarkReadRequestSchema.safeParse({ notificationIds: ids(2) }).success,
143
+ ).toBe(false);
144
+ });
145
+
146
+ it("a restore request needs a non-empty restore key", () => {
147
+ expect(
148
+ FeedRestoreRequestSchema.safeParse({ restoreKey: "op-1" }).success,
149
+ ).toBe(true);
150
+ expect(FeedRestoreRequestSchema.safeParse({ restoreKey: "" }).success).toBe(
151
+ false,
152
+ );
153
+ });
154
+ });
155
+
156
+ describe("the verb responses", () => {
157
+ it("a mutation reports a non-negative affected count", () => {
158
+ expect(FeedMutationResponseSchema.safeParse({ affected: 0 }).success).toBe(
159
+ true,
160
+ );
161
+ expect(FeedMutationResponseSchema.safeParse({ affected: -1 }).success).toBe(
162
+ false,
163
+ );
164
+ });
165
+
166
+ it("a delete returns an opaque restore key", () => {
167
+ expect(
168
+ FeedDeleteResponseSchema.safeParse({ affected: 2, restoreKey: "op-1" })
169
+ .success,
170
+ ).toBe(true);
171
+ expect(FeedDeleteResponseSchema.safeParse({ affected: 2 }).success).toBe(
172
+ false,
173
+ );
174
+ });
175
+
176
+ it("a restore response counts superseded rows", () => {
177
+ const parsed = FeedRestoreResponseSchema.parse({
178
+ restored: 1,
179
+ superseded: 1,
180
+ });
181
+ expect(parsed.superseded).toBe(1);
182
+ expect(FeedRestoreResponseSchema.safeParse({ restored: 1 }).success).toBe(
183
+ false,
184
+ );
185
+ });
186
+ });
187
+
188
+ describe("the inbox row's lifecycle fields", () => {
189
+ it("parses a superseded, unresolved row", () => {
190
+ expect(UserNotificationSchema.safeParse(makeNotification()).success).toBe(
191
+ true,
192
+ );
193
+ });
194
+
195
+ it("parses a resolved row", () => {
196
+ expect(
197
+ UserNotificationSchema.safeParse(
198
+ makeNotification({ resolvedAt: "2026-07-30T09:00:00.000Z" }),
199
+ ).success,
200
+ ).toBe(true);
201
+ });
202
+
203
+ it("a notification without activityAt fails to parse", () => {
204
+ const { activityAt: _omitted, ...withoutActivity } = makeNotification();
205
+ expect(UserNotificationSchema.safeParse(withoutActivity).success).toBe(
206
+ false,
207
+ );
208
+ });
209
+
210
+ it("requires occurrenceCount, and refuses zero", () => {
211
+ const { occurrenceCount: _omitted, ...withoutCount } = makeNotification();
212
+ expect(UserNotificationSchema.safeParse(withoutCount).success).toBe(false);
213
+ expect(
214
+ UserNotificationSchema.safeParse(makeNotification({ occurrenceCount: 0 }))
215
+ .success,
216
+ ).toBe(false);
217
+ });
218
+
219
+ it("requires resolvedAt — null says 'standing', absence says nothing", () => {
220
+ const { resolvedAt: _omitted, ...withoutResolved } = makeNotification();
221
+ expect(UserNotificationSchema.safeParse(withoutResolved).success).toBe(
222
+ false,
223
+ );
224
+ });
225
+
226
+ it("the feed response requires viewCount", () => {
227
+ const base = {
228
+ items: [],
229
+ nextCursor: null,
230
+ unreadCount: 0,
231
+ truncated: false,
232
+ };
233
+ expect(FeedListResponseSchema.safeParse(base).success).toBe(false);
234
+ expect(
235
+ FeedListResponseSchema.safeParse({ ...base, viewCount: 0 }).success,
236
+ ).toBe(true);
237
+ });
238
+ });
239
+
240
+ describe("barrel reachability", () => {
241
+ const values = {
242
+ FEED_SELECTOR_MAX_IDS,
243
+ FEED_VIEWS,
244
+ FeedDeleteRequestSchema,
245
+ FeedDeleteResponseSchema,
246
+ FeedMarkUnreadRequestSchema,
247
+ FeedMutationResponseSchema,
248
+ FeedRestoreRequestSchema,
249
+ FeedRestoreResponseSchema,
250
+ FeedSelectorSchema,
251
+ FeedViewSchema,
252
+ };
253
+
254
+ it("every feed-lifecycle value is exported from both barrels", () => {
255
+ for (const [name, value] of Object.entries(values)) {
256
+ expect((domain as Record<string, unknown>)[name]).toBe(value);
257
+ expect((root as Record<string, unknown>)[name]).toBe(value);
258
+ }
259
+ });
260
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Per-kind notification lifecycle (notification-lifecycle-is-declared-per-kind).
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - Every kind in USER_NOTIFICATION_KINDS declares exactly one lifecycle, and
6
+ * the registry names no kind outside the union (the annotation proves both at
7
+ * compile time; this proves it against the real runtime array).
8
+ * - A source-resolved kind always has a condition identity to resolve by.
9
+ * - Access requests are addressed per request and never supersede.
10
+ * - No kind is `persistent` yet — persistence is declared, never accidental.
11
+ * - The illegal lifecycles do not compile (./compile-fixtures.ts is the
12
+ * checked proof; the directives below restate it where it is read).
13
+ * - Every export is reachable from both the domain barrel and the root barrel.
14
+ */
15
+ import { describe, expect, it } from "vitest";
16
+ import * as root from "../../index";
17
+ import * as domain from "../index";
18
+ import { USER_NOTIFICATION_KINDS } from "../kinds";
19
+ import {
20
+ NOTIFICATION_ATTENTION_ON_SUPERSEDE,
21
+ NOTIFICATION_RESOLUTION_AUDIENCES,
22
+ NOTIFICATION_RETENTION_CLASSES,
23
+ type NotificationLifecycle,
24
+ } from "../lifecycle";
25
+ import { NOTIFICATION_LIFECYCLE } from "../lifecycle-registry";
26
+ import { ILLEGAL_LIFECYCLES } from "./compile-fixtures";
27
+
28
+ describe("NOTIFICATION_LIFECYCLE", () => {
29
+ it("every notification kind declares a lifecycle", () => {
30
+ for (const kind of USER_NOTIFICATION_KINDS) {
31
+ expect(NOTIFICATION_LIFECYCLE[kind], kind).toBeDefined();
32
+ }
33
+ expect(Object.keys(NOTIFICATION_LIFECYCLE).sort()).toEqual(
34
+ [...USER_NOTIFICATION_KINDS].sort(),
35
+ );
36
+ });
37
+
38
+ it("a source-resolved kind always has a condition identity", () => {
39
+ for (const [kind, lifecycle] of Object.entries(NOTIFICATION_LIFECYCLE)) {
40
+ if (lifecycle.resolution !== "never") {
41
+ expect(lifecycle.condition, kind).not.toBe("none");
42
+ }
43
+ }
44
+ });
45
+
46
+ it("access requests do not supersede one another", () => {
47
+ const lifecycle = NOTIFICATION_LIFECYCLE["companyMd.access_requested"];
48
+ expect(lifecycle.condition).toEqual({
49
+ scope: "fields",
50
+ fields: ["requestId"],
51
+ });
52
+ expect(lifecycle.condition !== "none" && lifecycle.supersedes).toBe(false);
53
+ });
54
+
55
+ it("no kind is persistent yet", () => {
56
+ for (const [kind, lifecycle] of Object.entries(NOTIFICATION_LIFECYCLE)) {
57
+ expect(lifecycle.retention, kind).toBe("standard");
58
+ }
59
+ });
60
+
61
+ it("the closed vocabularies are pinned member-for-member", () => {
62
+ expect(NOTIFICATION_ATTENTION_ON_SUPERSEDE).toEqual(["renew", "preserve"]);
63
+ expect(NOTIFICATION_RESOLUTION_AUDIENCES).toEqual([
64
+ "all-recipients",
65
+ "recipient",
66
+ ]);
67
+ expect(NOTIFICATION_RETENTION_CLASSES).toEqual(["standard", "persistent"]);
68
+ });
69
+
70
+ it("an illegal lifecycle does not compile", () => {
71
+ // Unchecked here (tsc excludes *.test.ts); ./compile-fixtures.ts holds the
72
+ // same three directives where `pnpm typecheck` enforces them.
73
+ const illegal: NotificationLifecycle[] = [
74
+ // @ts-expect-error — source resolution needs a condition
75
+ {
76
+ condition: "none",
77
+ resolution: { mode: "source", audience: "recipient" },
78
+ retention: "standard",
79
+ },
80
+ // @ts-expect-error — attention without supersession
81
+ {
82
+ condition: { scope: "kind" },
83
+ supersedes: false,
84
+ attentionOnSupersede: "renew",
85
+ resolution: "never",
86
+ retention: "standard",
87
+ },
88
+ // @ts-expect-error — a condition-less kind cannot supersede
89
+ {
90
+ condition: "none",
91
+ supersedes: true,
92
+ attentionOnSupersede: "renew",
93
+ resolution: "never",
94
+ retention: "standard",
95
+ },
96
+ ];
97
+ expect(illegal).toHaveLength(ILLEGAL_LIFECYCLES.length);
98
+ });
99
+
100
+ it("every lifecycle export is reachable from both barrels", () => {
101
+ for (const barrel of [root, domain]) {
102
+ expect(barrel.NOTIFICATION_LIFECYCLE).toBe(NOTIFICATION_LIFECYCLE);
103
+ expect(barrel.NOTIFICATION_ATTENTION_ON_SUPERSEDE).toBe(
104
+ NOTIFICATION_ATTENTION_ON_SUPERSEDE,
105
+ );
106
+ expect(barrel.NOTIFICATION_RESOLUTION_AUDIENCES).toBe(
107
+ NOTIFICATION_RESOLUTION_AUDIENCES,
108
+ );
109
+ expect(barrel.NOTIFICATION_RETENTION_CLASSES).toBe(
110
+ NOTIFICATION_RETENTION_CLASSES,
111
+ );
112
+ }
113
+ });
114
+ });
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The inbox vocabulary's invariants, as tests rather than prose (ADR-CONT-108).
2
+ * The inbox vocabulary's invariants, as tests rather than prose
3
+ * (user-notification-inbox-and-merged-feed).
3
4
  */
4
5
  import { describe, it, expect } from "vitest";
5
6
  import { USER_NOTIFICATION_KINDS } from "../kinds.js";
@@ -26,6 +27,9 @@ function makeNotification(over: Record<string, unknown> = {}) {
26
27
  href: "/@acme/md/doc-1",
27
28
  createdAt: "2026-07-30T12:00:00.000Z",
28
29
  readAt: null,
30
+ activityAt: "2026-07-30T12:00:00.000Z",
31
+ occurrenceCount: 1,
32
+ resolvedAt: null,
29
33
  ...over,
30
34
  };
31
35
  }
@@ -203,6 +207,7 @@ describe("FeedListResponse", () => {
203
207
  const base = {
204
208
  items: [],
205
209
  nextCursor: null,
210
+ viewCount: 0,
206
211
  unreadCount: 0,
207
212
  truncated: false,
208
213
  };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * THE key builder. The backend stores exactly what this returns and never
3
+ * builds a key any other way — two spellings of one condition would be two
4
+ * conditions, and resolution would silently miss.
5
+ *
6
+ * kind scope → "proactive.brief_morning"
7
+ * fields scope → "comment.reply|subjectId=doc-1|subjectType=company_md"
8
+ * (fields sorted by name; values encodeURIComponent'd, never
9
+ * trimmed or lowercased)
10
+ * none → null
11
+ *
12
+ * The supersession key is not a second identity: it is the condition key of a
13
+ * kind that supersedes, and null otherwise.
14
+ */
15
+ import type { UserNotificationKind } from "./kinds";
16
+ import { NOTIFICATION_LIFECYCLE } from "./lifecycle-registry";
17
+
18
+ /** A condition field the kind's lifecycle names is absent, not a string, or empty. */
19
+ // @vocabulary-exempt: Error subclass so callers can `instanceof` a malformed domain ref
20
+ export class NotificationConditionKeyError extends Error {
21
+ constructor(
22
+ readonly kind: UserNotificationKind,
23
+ readonly field: string,
24
+ ) {
25
+ super(
26
+ `notification kind ${kind} needs a non-empty string "${field}" in its domain ref`,
27
+ );
28
+ this.name = "NotificationConditionKeyError";
29
+ }
30
+ }
31
+
32
+ export function conditionKeyOf(
33
+ kind: UserNotificationKind,
34
+ domainRef: Readonly<Record<string, unknown>>,
35
+ ): string | null {
36
+ const { condition } = NOTIFICATION_LIFECYCLE[kind];
37
+ if (condition === "none") return null;
38
+ if (condition.scope === "kind") return kind;
39
+ const parts = [...condition.fields].sort().map((field) => {
40
+ const value = domainRef[field];
41
+ if (typeof value !== "string" || value.length === 0) {
42
+ throw new NotificationConditionKeyError(kind, field);
43
+ }
44
+ return `${field}=${encodeURIComponent(value)}`;
45
+ });
46
+ return [kind, ...parts].join("|");
47
+ }
48
+
49
+ export type NotificationKeys = {
50
+ /** Resolution identity. Null only for an independent (`condition: "none"`) kind. */
51
+ readonly conditionKey: string | null;
52
+ /** Active-row uniqueness identity: the condition key of a superseding kind, else null. */
53
+ readonly supersessionKey: string | null;
54
+ };
55
+
56
+ export function notificationKeysOf(
57
+ kind: UserNotificationKind,
58
+ domainRef: Readonly<Record<string, unknown>>,
59
+ ): NotificationKeys {
60
+ const lifecycle = NOTIFICATION_LIFECYCLE[kind];
61
+ const conditionKey = conditionKeyOf(kind, domainRef);
62
+ const supersedes = lifecycle.condition !== "none" && lifecycle.supersedes;
63
+ return { conditionKey, supersessionKey: supersedes ? conditionKey : null };
64
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The feed's views, selectors and verbs.
3
+ *
4
+ * A SELECTOR says which rows a verb touches. `ids` is what the person ticked;
5
+ * `view` is "everything in this view AS OF `asOf`" — rows that arrive after the
6
+ * person acted are never swept in (the snapshot rule). Verbs touch the caller's
7
+ * own notification rows only, never action items.
8
+ *
9
+ * There is no Archive: no archived view and no archive verb. A delete is undone
10
+ * by restoring with the opaque key it returned.
11
+ *
12
+ * Imports only `zod` — `./schemas` imports this module, never the reverse.
13
+ */
14
+ import { z } from "zod";
15
+
16
+ export const FEED_VIEWS = ["all", "unread"] as const;
17
+ export const FeedViewSchema = z.enum(FEED_VIEWS);
18
+ export type FeedView = z.infer<typeof FeedViewSchema>;
19
+
20
+ /** The most ids one explicit selection may name. */
21
+ export const FEED_SELECTOR_MAX_IDS = 200;
22
+
23
+ export const FeedSelectorSchema = z.discriminatedUnion("kind", [
24
+ z.object({
25
+ kind: z.literal("ids"),
26
+ notificationIds: z.array(z.string()).min(1).max(FEED_SELECTOR_MAX_IDS),
27
+ }),
28
+ z.object({
29
+ kind: z.literal("view"),
30
+ view: FeedViewSchema,
31
+ /** The snapshot instant: only rows whose activity is at or before it are touched. */
32
+ asOf: z.string().datetime(),
33
+ }),
34
+ ]);
35
+ export type FeedSelector = z.infer<typeof FeedSelectorSchema>;
36
+
37
+ /** Mark the selected informational rows unread. */
38
+ export const FeedMarkUnreadRequestSchema = z.object({
39
+ selector: FeedSelectorSchema,
40
+ });
41
+ export type FeedMarkUnreadRequest = z.infer<typeof FeedMarkUnreadRequestSchema>;
42
+
43
+ /** Delete the selected informational rows; undone by a restore. */
44
+ export const FeedDeleteRequestSchema = z.object({
45
+ selector: FeedSelectorSchema,
46
+ });
47
+ export type FeedDeleteRequest = z.infer<typeof FeedDeleteRequestSchema>;
48
+
49
+ /** Undo. `restoreKey` is the opaque operation id a delete returned. */
50
+ export const FeedRestoreRequestSchema = z.object({
51
+ restoreKey: z.string().min(1),
52
+ });
53
+ export type FeedRestoreRequest = z.infer<typeof FeedRestoreRequestSchema>;
54
+
55
+ /** A read / unread mutation: how many rows it changed. */
56
+ export const FeedMutationResponseSchema = z.object({
57
+ affected: z.number().int().nonnegative(),
58
+ });
59
+ export type FeedMutationResponse = z.infer<typeof FeedMutationResponseSchema>;
60
+
61
+ /** A delete (or clear-resolved). `restoreKey` is opaque — never parse it. */
62
+ export const FeedDeleteResponseSchema = z.object({
63
+ affected: z.number().int().nonnegative(),
64
+ restoreKey: z.string().min(1),
65
+ });
66
+ export type FeedDeleteResponse = z.infer<typeof FeedDeleteResponseSchema>;
67
+
68
+ /**
69
+ * `superseded` counts rows left deleted because a newer active row already
70
+ * represents their condition — a restore never duplicates a condition.
71
+ */
72
+ export const FeedRestoreResponseSchema = z.object({
73
+ restored: z.number().int().nonnegative(),
74
+ superseded: z.number().int().nonnegative(),
75
+ });
76
+ export type FeedRestoreResponse = z.infer<typeof FeedRestoreResponseSchema>;
@@ -2,13 +2,37 @@
2
2
  * user-notifications/ — the durable in-app inbox, and the merged feed that
3
3
  * renders it alongside action items.
4
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.
5
+ * See ./README.md for the domain, and user-notification-inbox-and-merged-feed
6
+ * for why this is a third vocabulary rather than a bend in either of the two
7
+ * beside it.
7
8
  */
8
9
 
9
10
  export { USER_NOTIFICATION_KINDS } from "./kinds";
10
11
  export type { UserNotificationKind } from "./kinds";
11
12
 
13
+ export {
14
+ NOTIFICATION_ATTENTION_ON_SUPERSEDE,
15
+ NOTIFICATION_RESOLUTION_AUDIENCES,
16
+ NOTIFICATION_RETENTION_CLASSES,
17
+ } from "./lifecycle";
18
+ export type {
19
+ NotificationAttentionOnSupersede,
20
+ NotificationCondition,
21
+ NotificationLifecycle,
22
+ NotificationResolution,
23
+ NotificationResolutionAudience,
24
+ NotificationRetention,
25
+ } from "./lifecycle";
26
+
27
+ export { NOTIFICATION_LIFECYCLE } from "./lifecycle-registry";
28
+
29
+ export {
30
+ conditionKeyOf,
31
+ NotificationConditionKeyError,
32
+ notificationKeysOf,
33
+ } from "./condition-keys";
34
+ export type { NotificationKeys } from "./condition-keys";
35
+
12
36
  export {
13
37
  FeedItemSchema,
14
38
  FeedListResponseSchema,
@@ -23,3 +47,26 @@ export type {
23
47
  FeedMarkReadRequest,
24
48
  UserNotification,
25
49
  } from "./schemas";
50
+
51
+ export {
52
+ FEED_SELECTOR_MAX_IDS,
53
+ FEED_VIEWS,
54
+ FeedDeleteRequestSchema,
55
+ FeedDeleteResponseSchema,
56
+ FeedMarkUnreadRequestSchema,
57
+ FeedMutationResponseSchema,
58
+ FeedRestoreRequestSchema,
59
+ FeedRestoreResponseSchema,
60
+ FeedSelectorSchema,
61
+ FeedViewSchema,
62
+ } from "./feed-lifecycle";
63
+ export type {
64
+ FeedDeleteRequest,
65
+ FeedDeleteResponse,
66
+ FeedMarkUnreadRequest,
67
+ FeedMutationResponse,
68
+ FeedRestoreRequest,
69
+ FeedRestoreResponse,
70
+ FeedSelector,
71
+ FeedView,
72
+ } from "./feed-lifecycle";
@@ -0,0 +1,123 @@
1
+ /**
2
+ * NOTIFICATION_LIFECYCLE — every inbox kind's declared lifecycle
3
+ * (notification-lifecycle-is-declared-per-kind). See ./lifecycle for what each
4
+ * axis means.
5
+ */
6
+ import type { UserNotificationKind } from "./kinds";
7
+ import type { NotificationLifecycle } from "./lifecycle";
8
+
9
+ const INDEPENDENT = {
10
+ condition: "none",
11
+ resolution: "never",
12
+ retention: "standard",
13
+ } as const;
14
+ const PER_KIND = { scope: "kind" } as const;
15
+ const RESOLVED_FOR_ALL = {
16
+ mode: "source",
17
+ audience: "all-recipients",
18
+ } as const;
19
+ /** One active row per kind; each repeat is news again. */
20
+ const RENEWING = {
21
+ condition: PER_KIND,
22
+ supersedes: true,
23
+ attentionOnSupersede: "renew",
24
+ resolution: "never",
25
+ retention: "standard",
26
+ } as const;
27
+ /** A standing condition: repeats update it quietly; the domain resolves it. */
28
+ const STANDING = {
29
+ condition: PER_KIND,
30
+ supersedes: true,
31
+ attentionOnSupersede: "preserve",
32
+ resolution: RESOLVED_FOR_ALL,
33
+ retention: "standard",
34
+ } as const;
35
+
36
+ /**
37
+ * Every kind's lifecycle. Annotated (not `satisfies`) so a missing kind and an
38
+ * excess key are both compile errors. Values mirror
39
+ * company-semantics-control docs/notification-inbox.md § Per-kind lifecycle.
40
+ * (prettier-ignore keeps the annotation on one line, where it is greppable.)
41
+ */
42
+ // prettier-ignore
43
+ export const NOTIFICATION_LIFECYCLE: Readonly<Record<UserNotificationKind, NotificationLifecycle>> = {
44
+ "companyMd.access_requested": {
45
+ condition: { scope: "fields", fields: ["requestId"] },
46
+ supersedes: false,
47
+ resolution: RESOLVED_FOR_ALL,
48
+ retention: "standard",
49
+ },
50
+ "companyMd.access_request_approved": INDEPENDENT,
51
+ "companyMd.access_request_denied": INDEPENDENT,
52
+ "comment.mention": {
53
+ condition: { scope: "fields", fields: ["subjectId", "subjectType"] },
54
+ supersedes: true,
55
+ attentionOnSupersede: "renew",
56
+ resolution: "never",
57
+ retention: "standard",
58
+ },
59
+ "comment.reply": {
60
+ condition: { scope: "fields", fields: ["subjectId", "subjectType"] },
61
+ supersedes: true,
62
+ attentionOnSupersede: "renew",
63
+ resolution: "never",
64
+ retention: "standard",
65
+ },
66
+ "chat.mention": {
67
+ condition: { scope: "fields", fields: ["chatId"] },
68
+ supersedes: true,
69
+ attentionOnSupersede: "renew",
70
+ resolution: "never",
71
+ retention: "standard",
72
+ },
73
+ "proactive.org_became_shared": INDEPENDENT,
74
+ "proactive.brief_morning": RENEWING,
75
+ "proactive.billing_balance_below_half": STANDING,
76
+ "proactive.billing_balance_below_threshold": STANDING,
77
+ "proactive.billing_balance_exhausted": STANDING,
78
+ "proactive.billing_auto_recharge_failed": STANDING,
79
+ "proactive.billing_payment_past_due": STANDING,
80
+ "proactive.billing_suspended": STANDING,
81
+ "proactive.billing_byo_key_invalid": {
82
+ condition: { scope: "fields", fields: ["configId"] },
83
+ supersedes: true,
84
+ attentionOnSupersede: "preserve",
85
+ resolution: RESOLVED_FOR_ALL,
86
+ retention: "standard",
87
+ },
88
+ "proactive.billing_user_monthly_limit_reached": {
89
+ condition: { scope: "fields", fields: ["userId"] },
90
+ supersedes: true,
91
+ attentionOnSupersede: "renew",
92
+ resolution: "never",
93
+ retention: "standard",
94
+ },
95
+ "proactive.billing_auto_recharge_succeeded": RENEWING,
96
+ "proactive.billing_top_up_credited": RENEWING,
97
+ "proactive.billing_monthly_budget_80": RENEWING,
98
+ "proactive.billing_monthly_budget_100": RENEWING,
99
+ "proactive.billing_provider_price_changed": RENEWING,
100
+ "proactive.billing_payment_recovered": RENEWING,
101
+ "proactive.billing_subscription_canceled": RENEWING,
102
+ "proactive.source_sync_docs_updated": {
103
+ condition: PER_KIND,
104
+ supersedes: true,
105
+ attentionOnSupersede: "preserve",
106
+ resolution: "never",
107
+ retention: "standard",
108
+ },
109
+ "proactive.source_sync_review_ready": {
110
+ condition: { scope: "fields", fields: ["checkpoint"] },
111
+ supersedes: true,
112
+ attentionOnSupersede: "preserve",
113
+ resolution: RESOLVED_FOR_ALL,
114
+ retention: "standard",
115
+ },
116
+ "proactive.org_structure_review_ready": {
117
+ condition: PER_KIND,
118
+ supersedes: true,
119
+ attentionOnSupersede: "renew",
120
+ resolution: RESOLVED_FOR_ALL,
121
+ retention: "standard",
122
+ },
123
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The lifecycle of an inbox row, declared per kind.
3
+ *
4
+ * A notification row is the CURRENT ATTENTION ITEM for one condition, not an
5
+ * event record (control ADR `notification-inbox-is-an-attention-surface`).
6
+ *
7
+ * condition — what the row is about; addresses RESOLUTION. `none` = an
8
+ * independent delivery with no identity.
9
+ * supersedes — a property of the condition: a repeat occurrence updates the
10
+ * active row instead of appending one.
11
+ * attentionOnSupersede — `renew` restores the kind's normal initial attention
12
+ * state (unread, or read when its class is born read);
13
+ * `preserve` keeps the row's current read state.
14
+ * resolution — `never`, or resolved by its producing DOMAIN (`source`) for
15
+ * `all-recipients` or only the `recipient`.
16
+ * retention — `standard`, or `persistent` (longer bounds; declared, never
17
+ * accidental).
18
+ *
19
+ * There is no Archive. See company-semantics-control docs/notification-inbox.md.
20
+ */
21
+
22
+ export const NOTIFICATION_ATTENTION_ON_SUPERSEDE = [
23
+ "renew",
24
+ "preserve",
25
+ ] as const;
26
+ export type NotificationAttentionOnSupersede =
27
+ (typeof NOTIFICATION_ATTENTION_ON_SUPERSEDE)[number];
28
+
29
+ export const NOTIFICATION_RESOLUTION_AUDIENCES = [
30
+ "all-recipients",
31
+ "recipient",
32
+ ] as const;
33
+ export type NotificationResolutionAudience =
34
+ (typeof NOTIFICATION_RESOLUTION_AUDIENCES)[number];
35
+
36
+ export const NOTIFICATION_RETENTION_CLASSES = [
37
+ "standard",
38
+ "persistent",
39
+ ] as const;
40
+ export type NotificationRetention =
41
+ (typeof NOTIFICATION_RETENTION_CLASSES)[number];
42
+
43
+ export type NotificationCondition =
44
+ | { readonly scope: "kind" }
45
+ | {
46
+ readonly scope: "fields";
47
+ readonly fields: readonly [string, ...string[]];
48
+ };
49
+
50
+ export type NotificationResolution =
51
+ | "never"
52
+ | {
53
+ readonly mode: "source";
54
+ readonly audience: NotificationResolutionAudience;
55
+ };
56
+
57
+ type Supersession =
58
+ | { readonly supersedes: false }
59
+ | {
60
+ readonly supersedes: true;
61
+ readonly attentionOnSupersede: NotificationAttentionOnSupersede;
62
+ };
63
+
64
+ export type NotificationLifecycle =
65
+ | {
66
+ /** An independent delivery: no identity, cannot resolve, cannot supersede. */
67
+ readonly condition: "none";
68
+ readonly resolution: "never";
69
+ readonly retention: NotificationRetention;
70
+ }
71
+ | ({
72
+ readonly condition: NotificationCondition;
73
+ readonly resolution: NotificationResolution;
74
+ readonly retention: NotificationRetention;
75
+ } & Supersession);
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { z } from "zod";
8
8
  import { ActionItemSchema, ActionItemTargetSchema } from "../action-items";
9
+ import { FeedSelectorSchema } from "./feed-lifecycle";
9
10
  import { USER_NOTIFICATION_KINDS } from "./kinds";
10
11
 
11
12
  /** Zod mirror of the vocabulary. `./kinds` owns the list; this never restates it. */
@@ -68,9 +69,22 @@ export const UserNotificationSchema = z.object({
68
69
  * longer see" is itself true and useful; it simply does not link anywhere.
69
70
  */
70
71
  href: z.string().nullable(),
72
+ /** When the row was first written — its FIRST occurrence. */
71
73
  createdAt: z.string(),
72
- /** `null` while unread. The only mutable field on the row. */
74
+ /** `null` while unread. Changed by the read/unread verbs and by supersession. */
73
75
  readAt: z.string().nullable(),
76
+ /**
77
+ * The time of the LATEST occurrence this row represents — the ordering and
78
+ * retention clock. Equals `createdAt` until a repeat supersedes into the row.
79
+ */
80
+ activityAt: z.string(),
81
+ /** How many occurrences this row has absorbed by supersession. 1 for a fresh row. */
82
+ occurrenceCount: z.number().int().positive(),
83
+ /**
84
+ * Set when the producing domain resolved the row's condition; `null` while
85
+ * it stands. A resolved row still renders (muted) until retention removes it.
86
+ */
87
+ resolvedAt: z.string().nullable(),
74
88
  });
75
89
  export type UserNotification = z.infer<typeof UserNotificationSchema>;
76
90
 
@@ -103,7 +117,8 @@ export type FeedItem = z.infer<typeof FeedItemSchema>;
103
117
  * `GET /api/me/feed`.
104
118
  *
105
119
  * ORDERING IS INTENTIONALLY GROUPED, NOT CHRONOLOGICALLY INTERLEAVED: every
106
- * `actionable` row first, then `informational` rows newest-first. "Merged feed"
120
+ * `actionable` row first, then `informational` rows — unread-and-unresolved
121
+ * first, then `activityAt` DESC, `id` DESC. "Merged feed"
107
122
  * names one visual surface, not one timeline. Grouping is what keeps "what do I
108
123
  * owe?" answerable at a glance instead of scattered between announcements — and
109
124
  * it is what makes the pagination rule below coherent.
@@ -119,10 +134,16 @@ export const FeedListResponseSchema = z.object({
119
134
  /**
120
135
  * Opaque cursor for the NEXT page of informational rows; `null` at the end.
121
136
  *
122
- * Encodes `(createdAt, id)` — `id` breaks ties so two rows written in the same
123
- * transaction cannot straddle a page boundary and be served twice or skipped.
137
+ * Encodes `(unread, activityAt, id)`, mirroring the ordering above — `id`
138
+ * breaks ties so two rows with the same activity cannot straddle a page
139
+ * boundary and be served twice or skipped. Never parse it.
124
140
  */
125
141
  nextCursor: z.string().nullable(),
142
+ /**
143
+ * The number of notification rows matching the requested view — never action
144
+ * items, never a window. What "Select all in this view" would touch.
145
+ */
146
+ viewCount: z.number().int().nonnegative(),
126
147
  /**
127
148
  * Total unread across ALL retained notifications — NOT within the returned
128
149
  * window.
@@ -136,8 +157,8 @@ export const FeedListResponseSchema = z.object({
136
157
  });
137
158
  export type FeedListResponse = z.infer<typeof FeedListResponseSchema>;
138
159
 
139
- /** `POST /api/me/feed/read` — mark specific informational rows read. */
160
+ /** `POST /api/me/feed/read` — mark the selected informational rows read. */
140
161
  export const FeedMarkReadRequestSchema = z.object({
141
- notificationIds: z.array(z.string()).min(1).max(200),
162
+ selector: FeedSelectorSchema,
142
163
  });
143
164
  export type FeedMarkReadRequest = z.infer<typeof FeedMarkReadRequestSchema>;