@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.
- package/package.json +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +19 -0
- package/src/chat/README.md +33 -1
- package/src/chat/__tests__/compaction-boundary.test.ts +1 -0
- package/src/chat/__tests__/continuity.test.ts +402 -0
- package/src/chat/__tests__/message-mentions.test.ts +1 -0
- package/src/chat/__tests__/multi-party.test.ts +1 -0
- package/src/chat/continuity.ts +207 -0
- package/src/chat/index.ts +26 -0
- package/src/chat/schemas.ts +7 -0
- package/src/index.ts +77 -1
- package/src/user-notifications/README.md +99 -12
- package/src/user-notifications/__tests__/README.md +19 -0
- package/src/user-notifications/__tests__/compile-fixtures.ts +54 -0
- package/src/user-notifications/__tests__/condition-keys.test.ts +126 -0
- package/src/user-notifications/__tests__/feed-lifecycle.test.ts +260 -0
- package/src/user-notifications/__tests__/lifecycle.test.ts +114 -0
- package/src/user-notifications/__tests__/vocabulary.test.ts +6 -1
- package/src/user-notifications/condition-keys.ts +64 -0
- package/src/user-notifications/feed-lifecycle.ts +76 -0
- package/src/user-notifications/index.ts +49 -2
- package/src/user-notifications/lifecycle-registry.ts +123 -0
- package/src/user-notifications/lifecycle.ts +75 -0
- package/src/user-notifications/schemas.ts +27 -6
|
@@ -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,
|
package/src/chat/schemas.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 `(
|
|
34
|
-
|
|
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
|
|
89
|
-
|
|
|
90
|
-
| `UserNotificationSchema`
|
|
91
|
-
| `USER_NOTIFICATION_KINDS`
|
|
92
|
-
| `FeedItemSchema`
|
|
93
|
-
| `FeedListResponseSchema`
|
|
94
|
-
| `FeedMarkReadRequestSchema`
|
|
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;
|