@company-semantics/contracts 81.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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Chat continuity wire vocabulary (chat-continuity-wire-vocabulary).
3
+ *
4
+ * A chat can belong to a SUBJECT — the durable case a hand-off to chat is
5
+ * about (a candidate review, a synthesis review, an org-structure review).
6
+ * A subject is worked in GENERATIONS: generation 1 is the first chat about
7
+ * it, and each "start fresh" mints the next generation linked back to its
8
+ * predecessor. Exactly one generation is active at a time, or none.
9
+ *
10
+ * Three identities, never conflated:
11
+ * - the SUBJECT (`subjectId`) — the case, stable across every generation
12
+ * - the GENERATION (`generation`) — which chat of the case this is
13
+ * - the CHAT (`chatId`) — the ordinary chat row carrying that generation
14
+ *
15
+ * The contracts own only the wire shapes. Continuity POLICY — which launch
16
+ * kinds map onto which subject kind, what a subject is titled, when a new
17
+ * generation starts — is server-owned and deliberately absent here.
18
+ *
19
+ * LOAD ORDER: this module imports only zod and the shared primitives, and
20
+ * must NEVER import `./schemas` — `./schemas` imports this file, and the
21
+ * reverse edge would reach its declarations in the temporal dead zone.
22
+ * Types are declared here beside their schemas, as `./runtime-profile` does.
23
+ */
24
+
25
+ import { z } from "zod";
26
+ import { IsoDateTime } from "../api/primitives";
27
+
28
+ /**
29
+ * The kinds of subject a chat can continue. CLOSED and append-only: a new
30
+ * kind is a new hand-off surface, and a consumer switching on this union
31
+ * must be told about it by the compiler.
32
+ *
33
+ * Deliberately NOT the input-request types or the launch kinds — the server
34
+ * maps those onto these, and that mapping is policy, not vocabulary.
35
+ */
36
+ export const CONVERSATION_SUBJECT_KINDS = [
37
+ /** Reviewing the candidate files a source sync proposes to include. */
38
+ "source-sync-candidate-review",
39
+ /** Reviewing the synthesis a source sync produced. */
40
+ "source-sync-synthesis-review",
41
+ /** Reviewing the org structure. */
42
+ "org-structure-review",
43
+ ] as const;
44
+
45
+ export const ConversationSubjectKindSchema = z.enum(CONVERSATION_SUBJECT_KINDS);
46
+ export type ConversationSubjectKind = z.infer<
47
+ typeof ConversationSubjectKindSchema
48
+ >;
49
+
50
+ /**
51
+ * Why a generation continues its predecessor. CLOSED, and grown ONLY when a
52
+ * real trigger exists: today the only way a new generation starts is the
53
+ * reader choosing to start fresh. Speculative reasons are not wire values.
54
+ */
55
+ export const CHAT_CONTINUATION_REASONS = [
56
+ /** The reader explicitly started a fresh chat on the same subject. */
57
+ "user-started-fresh",
58
+ ] as const;
59
+
60
+ export const ChatContinuationReasonSchema = z.enum(CHAT_CONTINUATION_REASONS);
61
+ export type ChatContinuationReason = z.infer<
62
+ typeof ChatContinuationReasonSchema
63
+ >;
64
+
65
+ /**
66
+ * Where a chat sits in its subject's lineage — carried on the chat detail.
67
+ *
68
+ * `continuationOf` and `continuationReason` are null TOGETHER or non-null
69
+ * TOGETHER; the refinement rejects a reason without a predecessor and a
70
+ * predecessor without a reason.
71
+ */
72
+ export const ChatContinuitySchema = z
73
+ .object({
74
+ /** The subject (case) this chat belongs to — stable across generations. */
75
+ subjectId: z.string(),
76
+ subjectKind: ConversationSubjectKindSchema,
77
+ /** Server-authored display title of the subject. */
78
+ subjectTitle: z.string(),
79
+ /** Which generation of the subject this chat is; the first is 1. */
80
+ generation: z.number().int().positive(),
81
+ /** Whether THIS chat is the subject's active generation. */
82
+ isActiveGeneration: z.boolean(),
83
+ /**
84
+ * Where the case continues NOW. Equals this chat's own id when it is the
85
+ * active generation; another chat's id when a later generation took over;
86
+ * NULL when the subject currently has no active generation.
87
+ */
88
+ activeChatId: z.string().nullable(),
89
+ /**
90
+ * The generation this chat continues. NULL for generation 1, for a
91
+ * generation started after its predecessor was DELETED, and when the
92
+ * predecessor row no longer exists — a dangling link is never sent.
93
+ */
94
+ continuationOf: z
95
+ .object({
96
+ chatId: z.string(),
97
+ createdAt: IsoDateTime,
98
+ })
99
+ .nullable(),
100
+ /** Why this generation continues its predecessor; null exactly when `continuationOf` is. */
101
+ continuationReason: ChatContinuationReasonSchema.nullable(),
102
+ })
103
+ .refine(
104
+ (continuity) =>
105
+ (continuity.continuationOf === null) ===
106
+ (continuity.continuationReason === null),
107
+ {
108
+ message:
109
+ "continuationReason must be null exactly when continuationOf is null",
110
+ path: ["continuationReason"],
111
+ },
112
+ );
113
+ export type ChatContinuity = z.infer<typeof ChatContinuitySchema>;
114
+
115
+ /**
116
+ * What the read-only launch resolution found for a hand-off. CLOSED: the
117
+ * client branches on it to decide whether to open an existing chat, open it
118
+ * and post the launch, or create a chat and post the launch.
119
+ */
120
+ export const LAUNCH_DISPOSITIONS = [
121
+ /** The subject's active generation already carries this launch — just open it. */
122
+ "resume-existing-anchor",
123
+ /** Open the subject's active generation and post the launch into it. */
124
+ "resume-and-launch",
125
+ /** No active generation to resume — the launch write creates the chat. */
126
+ "created-and-launch",
127
+ ] as const;
128
+
129
+ export const LaunchDispositionSchema = z.enum(LAUNCH_DISPOSITIONS);
130
+ export type LaunchDisposition = z.infer<typeof LaunchDispositionSchema>;
131
+
132
+ /**
133
+ * Response of the read-only launch resolution.
134
+ *
135
+ * Resolution is READ-ONLY and never creates a chat, so `created-and-launch`
136
+ * carries a null `chatId` AND a null `generation`; its `subjectId` is still
137
+ * reported when the subject row already exists (a stable subject with no
138
+ * active generation), else null. Both `resume-*` dispositions name an
139
+ * existing chat, so `subjectId`, `chatId` and `generation` are all non-null.
140
+ * The refinement rejects every other combination.
141
+ */
142
+ export const ResolveLaunchResponseSchema = z
143
+ .object({
144
+ subjectId: z.string().nullable(),
145
+ chatId: z.string().nullable(),
146
+ generation: z.number().int().positive().nullable(),
147
+ disposition: LaunchDispositionSchema,
148
+ })
149
+ .superRefine((response, ctx) => {
150
+ if (response.disposition === "created-and-launch") {
151
+ if (response.chatId !== null || response.generation !== null) {
152
+ ctx.addIssue({
153
+ code: "custom",
154
+ message:
155
+ "created-and-launch must carry a null chatId and a null generation — resolution never creates a chat",
156
+ path: ["disposition"],
157
+ });
158
+ }
159
+ return;
160
+ }
161
+ for (const field of ["subjectId", "chatId", "generation"] as const) {
162
+ if (response[field] === null) {
163
+ ctx.addIssue({
164
+ code: "custom",
165
+ message: `${response.disposition} must name the chat it resumes: ${field} is required`,
166
+ path: [field],
167
+ });
168
+ }
169
+ }
170
+ });
171
+ export type ResolveLaunchResponse = z.infer<typeof ResolveLaunchResponseSchema>;
172
+
173
+ /**
174
+ * Response of the start-fresh write: the NEW active generation of the
175
+ * subject, linked back to the chat it continues. Start-fresh always has a
176
+ * predecessor — the chat it was invoked on — so `continuationOf` is required.
177
+ */
178
+ export const StartFreshChatResponseSchema = z.object({
179
+ chatId: z.string(),
180
+ interactionId: z.string(),
181
+ subjectId: z.string(),
182
+ generation: z.number().int().positive(),
183
+ continuationOf: z.object({
184
+ chatId: z.string(),
185
+ }),
186
+ });
187
+ export type StartFreshChatResponse = z.infer<
188
+ typeof StartFreshChatResponseSchema
189
+ >;
190
+
191
+ /** The 409 code for a launch posted into a chat that is not the subject's active generation. */
192
+ export const LAUNCH_TARGET_SUPERSEDED_CODE =
193
+ "launch_target_superseded" as const;
194
+
195
+ /**
196
+ * The 409 body the authoritative launch write returns when the posted chat
197
+ * is not — or can no longer become — the subject's active generation.
198
+ * `activeChatId` names where the case continues now; NULL means there is no
199
+ * active generation and the client should start a new chat.
200
+ */
201
+ export const LaunchTargetSupersededSchema = z.object({
202
+ code: z.literal(LAUNCH_TARGET_SUPERSEDED_CODE),
203
+ activeChatId: z.string().nullable(),
204
+ });
205
+ export type LaunchTargetSuperseded = z.infer<
206
+ typeof LaunchTargetSupersededSchema
207
+ >;
package/src/chat/index.ts CHANGED
@@ -124,6 +124,32 @@ export {
124
124
  export type { RecognizedProactiveKind } from "./proactive-kind";
125
125
  export { recognizeProactiveKind } from "./proactive-kind";
126
126
 
127
+ // Chat continuity (chat-continuity-wire-vocabulary): subjects, generations
128
+ // and the lineage a chat detail carries; the launch-resolution disposition,
129
+ // the start-fresh response and the launch-target 409
130
+ export type {
131
+ ConversationSubjectKind,
132
+ ChatContinuationReason,
133
+ ChatContinuity,
134
+ LaunchDisposition,
135
+ ResolveLaunchResponse,
136
+ StartFreshChatResponse,
137
+ LaunchTargetSuperseded,
138
+ } from "./continuity";
139
+ export {
140
+ CONVERSATION_SUBJECT_KINDS,
141
+ ConversationSubjectKindSchema,
142
+ CHAT_CONTINUATION_REASONS,
143
+ ChatContinuationReasonSchema,
144
+ ChatContinuitySchema,
145
+ LAUNCH_DISPOSITIONS,
146
+ LaunchDispositionSchema,
147
+ ResolveLaunchResponseSchema,
148
+ StartFreshChatResponseSchema,
149
+ LAUNCH_TARGET_SUPERSEDED_CODE,
150
+ LaunchTargetSupersededSchema,
151
+ } from "./continuity";
152
+
127
153
  // Runtime profile types and constants
128
154
  export type {
129
155
  ChatRuntimeProfile,
@@ -18,6 +18,7 @@ import { z } from "zod";
18
18
  import { IsoDateTime } from "../api/primitives";
19
19
  import { MentionableCandidateSchema } from "../comments/schemas";
20
20
  import { PrincipalRefSchema } from "../identity/agent";
21
+ import { ChatContinuitySchema } from "./continuity";
21
22
  import {
22
23
  ChatParticipantRoleSchema,
23
24
  ChatVisibilitySchema,
@@ -468,6 +469,12 @@ export const ChatDetailSchema = z.object({
468
469
  * an empty array is not a state the server produces.
469
470
  */
470
471
  participants: z.array(ChatParticipantSchema).optional(),
472
+ /**
473
+ * The chat's place in its subject's lineage (chat-continuity-wire-vocabulary).
474
+ * NULL for a chat that belongs to no subject. REQUIRED, not optional: a
475
+ * missing one is a backend that has not adopted this release.
476
+ */
477
+ continuity: ChatContinuitySchema.nullable(),
471
478
  });
472
479
 
473
480
  /** Response for GET /api/chats/:id */
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
@@ -589,6 +639,17 @@ export type {
589
639
  ChatParticipantRole,
590
640
  ChatParticipant,
591
641
  ChatParticipantsResponse,
642
+ // Chat continuity (chat-continuity-wire-vocabulary): the subject a chat
643
+ // belongs to, its generation, and the lineage back to its predecessor.
644
+ ConversationSubjectKind,
645
+ ChatContinuationReason,
646
+ ChatContinuity,
647
+ // Launch resolution: the disposition the read-only resolve reports, the
648
+ // start-fresh response, and the launch-target-superseded 409 body.
649
+ LaunchDisposition,
650
+ ResolveLaunchResponse,
651
+ StartFreshChatResponse,
652
+ LaunchTargetSuperseded,
592
653
  } from "./chat/index";
593
654
 
594
655
  export {
@@ -616,6 +677,21 @@ export {
616
677
  ChatParticipantRoleSchema,
617
678
  ChatParticipantSchema,
618
679
  ChatParticipantsResponseSchema,
680
+ // Chat continuity: the closed subject-kind and continuation-reason
681
+ // vocabularies, and the lineage object on the chat detail.
682
+ CONVERSATION_SUBJECT_KINDS,
683
+ ConversationSubjectKindSchema,
684
+ CHAT_CONTINUATION_REASONS,
685
+ ChatContinuationReasonSchema,
686
+ ChatContinuitySchema,
687
+ // Launch resolution: the closed disposition vocabulary, the resolve and
688
+ // start-fresh responses, and the 409 code + body for a superseded target.
689
+ LAUNCH_DISPOSITIONS,
690
+ LaunchDispositionSchema,
691
+ ResolveLaunchResponseSchema,
692
+ StartFreshChatResponseSchema,
693
+ LAUNCH_TARGET_SUPERSEDED_CODE,
694
+ LaunchTargetSupersededSchema,
619
695
  } from "./chat/index";
620
696
 
621
697
  // Organization domain types
@@ -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;