@company-semantics/contracts 82.0.0 → 83.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": "82.0.0",
3
+ "version": "83.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 = '2e37dedd5ad7' as const;
3
- export const SPEC_HASH_FULL = '2e37dedd5ad79e5b8ea6f6b470525a074c62c4a1bdf642b4792db0f893af887f' as const;
2
+ export const SPEC_HASH = 'e485ddbb7682' as const;
3
+ export const SPEC_HASH_FULL = 'e485ddbb7682e98908329d4b788ce63dd32e00c23a6360e344d953ae9a42fc0e' as const;
@@ -281,6 +281,23 @@ export interface paths {
281
281
  patch?: never;
282
282
  trace?: never;
283
283
  };
284
+ "/api/chats/resolve-launch": {
285
+ parameters: {
286
+ query?: never;
287
+ header?: never;
288
+ path?: never;
289
+ cookie?: never;
290
+ };
291
+ get?: never;
292
+ put?: never;
293
+ /** Resolve a launch to its subject’s active chat (read-only) */
294
+ post: operations["resolveChatLaunch"];
295
+ delete?: never;
296
+ options?: never;
297
+ head?: never;
298
+ patch?: never;
299
+ trace?: never;
300
+ };
284
301
  "/api/chats/{id}": {
285
302
  parameters: {
286
303
  query?: never;
@@ -369,6 +386,23 @@ export interface paths {
369
386
  patch?: never;
370
387
  trace?: never;
371
388
  };
389
+ "/api/chats/{id}/start-fresh": {
390
+ parameters: {
391
+ query?: never;
392
+ header?: never;
393
+ path?: never;
394
+ cookie?: never;
395
+ };
396
+ get?: never;
397
+ put?: never;
398
+ /** Start a fresh chat on the same subject, handing over a summary */
399
+ post: operations["startFreshChat"];
400
+ delete?: never;
401
+ options?: never;
402
+ head?: never;
403
+ patch?: never;
404
+ trace?: never;
405
+ };
372
406
  "/api/chats/{id}/generate-title": {
373
407
  parameters: {
374
408
  query?: never;
@@ -4865,6 +4899,34 @@ export interface components {
4865
4899
  avatarUrl: string | null;
4866
4900
  }[];
4867
4901
  };
4902
+ ResolveLaunchResponse: {
4903
+ subjectId: string | null;
4904
+ chatId: string | null;
4905
+ generation: number | null;
4906
+ /** @enum {string} */
4907
+ disposition: "resume-existing-anchor" | "resume-and-launch" | "created-and-launch";
4908
+ };
4909
+ /** @description The launch to resolve against its subject’s active generation. */
4910
+ ResolveLaunchRequest: {
4911
+ launch: {
4912
+ /** @constant */
4913
+ kind: "structure-review";
4914
+ /** Format: uuid */
4915
+ proposalId: string;
4916
+ } | {
4917
+ /** @constant */
4918
+ kind: "workflow";
4919
+ /** Format: uuid */
4920
+ workflowId: string;
4921
+ } | {
4922
+ /** @constant */
4923
+ kind: "input-request";
4924
+ /** @enum {string} */
4925
+ requestType: "org-structure/review" | "source-sync/candidates" | "source-sync/synthesis";
4926
+ /** Format: uuid */
4927
+ requestId: string;
4928
+ };
4929
+ };
4868
4930
  GetChatResponse: {
4869
4931
  chat: {
4870
4932
  id: string;
@@ -4973,6 +5035,20 @@ export interface components {
4973
5035
  /** Format: uuid */
4974
5036
  userId: string;
4975
5037
  };
5038
+ StartFreshChatResponse: {
5039
+ chatId: string;
5040
+ interactionId: string;
5041
+ subjectId: string;
5042
+ generation: number;
5043
+ continuationOf: {
5044
+ chatId: string;
5045
+ };
5046
+ };
5047
+ LaunchTargetSuperseded: {
5048
+ /** @constant */
5049
+ code: "launch_target_superseded";
5050
+ activeChatId: string | null;
5051
+ };
4976
5052
  GenerateTitleResponse: {
4977
5053
  title: string;
4978
5054
  isAutoGenerated: boolean;
@@ -7629,9 +7705,13 @@ export interface components {
7629
7705
  href: string | null;
7630
7706
  createdAt: string;
7631
7707
  readAt: string | null;
7708
+ activityAt: string;
7709
+ occurrenceCount: number;
7710
+ resolvedAt: string | null;
7632
7711
  };
7633
7712
  })[];
7634
7713
  nextCursor: string | null;
7714
+ viewCount: number;
7635
7715
  unreadCount: number;
7636
7716
  truncated: boolean;
7637
7717
  };
@@ -10050,6 +10130,8 @@ export interface operations {
10050
10130
  limit?: number;
10051
10131
  cursor?: string;
10052
10132
  includePinned?: "true" | "false";
10133
+ surface?: "recents";
10134
+ subjectId?: string;
10053
10135
  };
10054
10136
  header?: never;
10055
10137
  path?: never;
@@ -10136,6 +10218,30 @@ export interface operations {
10136
10218
  };
10137
10219
  };
10138
10220
  };
10221
+ resolveChatLaunch: {
10222
+ parameters: {
10223
+ query?: never;
10224
+ header?: never;
10225
+ path?: never;
10226
+ cookie?: never;
10227
+ };
10228
+ requestBody: {
10229
+ content: {
10230
+ "application/json": components["schemas"]["ResolveLaunchRequest"];
10231
+ };
10232
+ };
10233
+ responses: {
10234
+ /** @description Where the launch would land; nothing is written */
10235
+ 200: {
10236
+ headers: {
10237
+ [name: string]: unknown;
10238
+ };
10239
+ content: {
10240
+ "application/json": components["schemas"]["ResolveLaunchResponse"];
10241
+ };
10242
+ };
10243
+ };
10244
+ };
10139
10245
  getChat: {
10140
10246
  parameters: {
10141
10247
  query?: never;
@@ -10326,6 +10432,37 @@ export interface operations {
10326
10432
  };
10327
10433
  };
10328
10434
  };
10435
+ startFreshChat: {
10436
+ parameters: {
10437
+ query?: never;
10438
+ header?: never;
10439
+ path: {
10440
+ id: string;
10441
+ };
10442
+ cookie?: never;
10443
+ };
10444
+ requestBody?: never;
10445
+ responses: {
10446
+ /** @description The new active chat of the subject */
10447
+ 200: {
10448
+ headers: {
10449
+ [name: string]: unknown;
10450
+ };
10451
+ content: {
10452
+ "application/json": components["schemas"]["StartFreshChatResponse"];
10453
+ };
10454
+ };
10455
+ /** @description The chat is not its subject’s active chat */
10456
+ 409: {
10457
+ headers: {
10458
+ [name: string]: unknown;
10459
+ };
10460
+ content: {
10461
+ "application/json": components["schemas"]["LaunchTargetSuperseded"];
10462
+ };
10463
+ };
10464
+ };
10465
+ };
10329
10466
  generateChatTitle: {
10330
10467
  parameters: {
10331
10468
  query?: never;
@@ -20,6 +20,7 @@ export const openApiRoutes = {
20
20
  '/api/chats/events': ['GET'],
21
21
  '/api/chats/mentionable': ['GET'],
22
22
  '/api/chats/proactive/{eventId}': ['GET'],
23
+ '/api/chats/resolve-launch': ['POST'],
23
24
  '/api/chats/{chatId}/shares': ['GET', 'POST'],
24
25
  '/api/chats/{id}': ['DELETE', 'GET', 'PATCH'],
25
26
  '/api/chats/{id}/generate-title': ['POST'],
@@ -28,6 +29,7 @@ export const openApiRoutes = {
28
29
  '/api/chats/{id}/participants': ['GET', 'POST'],
29
30
  '/api/chats/{id}/participants/{userId}': ['DELETE'],
30
31
  '/api/chats/{id}/pin': ['DELETE', 'POST'],
32
+ '/api/chats/{id}/start-fresh': ['POST'],
31
33
  '/api/comments': ['GET'],
32
34
  '/api/comments/threads': ['POST'],
33
35
  '/api/comments/threads/{threadId}': ['DELETE'],
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
+ });