@company-semantics/contracts 81.0.0 → 82.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": "81.0.0",
3
+ "version": "82.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 = '7e0656a03459' as const;
3
- export const SPEC_HASH_FULL = '7e0656a03459e90e26051fe9aea403e95f0de11fea465f6a0f00ae5caf8b0aa7' as const;
2
+ export const SPEC_HASH = '2e37dedd5ad7' as const;
3
+ export const SPEC_HASH_FULL = '2e37dedd5ad79e5b8ea6f6b470525a074c62c4a1bdf642b4792db0f893af887f' as const;
@@ -4889,6 +4889,21 @@ export interface components {
4889
4889
  /** Format: date-time */
4890
4890
  addedAt: string;
4891
4891
  }[];
4892
+ continuity: {
4893
+ subjectId: string;
4894
+ /** @enum {string} */
4895
+ subjectKind: "source-sync-candidate-review" | "source-sync-synthesis-review" | "org-structure-review";
4896
+ subjectTitle: string;
4897
+ generation: number;
4898
+ isActiveGeneration: boolean;
4899
+ activeChatId: string | null;
4900
+ continuationOf: {
4901
+ chatId: string;
4902
+ /** Format: date-time */
4903
+ createdAt: string;
4904
+ } | null;
4905
+ continuationReason: "user-started-fresh" | null;
4906
+ } | null;
4892
4907
  };
4893
4908
  messages: {
4894
4909
  id: string;
@@ -38,24 +38,44 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
38
38
  - The summary carries a participant COUNT, never the roster. A 50-row sidebar page must not carry 50 rosters, and a chat's membership is not disclosed by the chat merely appearing in a list. `ChatDetail.participants` is where the names are, and it is optional — a missing roster is simply not drawn, whereas a guessed role is drawn wrong
39
39
  - A `ChatParticipant` is a USER, never a `PrincipalRef` — the deliberate asymmetry with a mention TARGET, which is a ref precisely so the agent can be named. The agent takes part in every chat by construction and has no participant row: it is mentionable, not a member, and cannot be added, removed or left
40
40
  - `ChatParticipantRole` has EXACTLY two members. A third would be an access band, and this is participation rather than an ACL: a participant sees the whole conversation, `owner` names the one person who additionally deletes, renames, pins and shares it, and a chat you can read half of is not a thing this surface models
41
- - The chat schemas are THREE files with a one-way load order: `enums.ts` (the closed vocabularies plus the `proactiveChatFields` / `multiPartyChatFields` groups) imports nothing from the domain, `event-schemas.ts` imports only from it, and `schemas.ts` imports from both and RE-EXPORTS them so `index.ts`, `types.ts` and the tests keep one import site. An import from either of the first two back into `schemas.ts` is a load cycle: the re-export edge is evaluated before `schemas.ts`'s body, so the shared declarations are reached in the temporal dead zone and every consumer throws a `ReferenceError` at import time
41
+ - The chat schemas are THREE files with a one-way load order: `enums.ts` (the closed vocabularies plus the `proactiveChatFields` / `multiPartyChatFields` groups) imports nothing from the domain, `event-schemas.ts` imports only from it, and `schemas.ts` imports from both and RE-EXPORTS them so `index.ts`, `types.ts` and the tests keep one import site. An import from either of the first two back into `schemas.ts` is a load cycle: the re-export edge is evaluated before `schemas.ts`'s body, so the shared declarations are reached in the temporal dead zone and every consumer throws a `ReferenceError` at import time. `continuity.ts` sits outside that set on the same terms: `schemas.ts` imports it, it never imports `schemas.ts`, and `index.ts` re-exports it directly (its types are declared beside its schemas, as `runtime-profile.ts` does)
42
42
  - `CHAT_PAGE_VIEWS` and `CHAT_PAGE_SCOPES` are a projection of the app's `Route` union (`company-semantics-app/src/platform/route-parser.ts`), not a parallel vocabulary. A view the parser can emit but the schema rejects fails the send outright rather than degrading
43
43
 
44
+ ## Continuity
45
+
46
+ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about — and a subject is worked in numbered GENERATIONS, each a chat linked back to its predecessor (`src/chat/continuity.ts`, ADR slug `chat-continuity-wire-vocabulary`).
47
+
48
+ - `ChatDetail.continuity` is REQUIRED and NULLABLE: null means the chat belongs to no subject; a missing key is a backend that has not adopted the release
49
+ - Three identities, never conflated: `subjectId` (the case), `generation` (1-based position in the lineage), and the chat id carrying it. `activeChatId` is where the case continues now — this chat's own id when it is active, null when the subject has no active generation
50
+ - `continuationOf` and `continuationReason` are null together: generation 1, a generation started after its predecessor was deleted, and a predecessor row that no longer exists all send null, never a dangling link
51
+ - Compaction NEVER changes subject or generation identity. Compacting a chat (`compactedThroughSequence`) summarizes the older turns of the SAME chat — it keeps the same `subjectId`, the same `generation` and the same chat id, and it is not a continuation. A new generation starts only through a `continuationReason`, and compaction is deliberately not one
52
+ - `CONVERSATION_SUBJECT_KINDS` and `CHAT_CONTINUATION_REASONS` are CLOSED. A reason is added only when a real trigger exists; the launch-kind → subject-kind mapping and subject titles are server policy and do not live here
53
+ - Launch resolution is READ-ONLY and never creates a chat. `LAUNCH_DISPOSITIONS` is CLOSED; `created-and-launch` carries a null `chatId` AND a null `generation` (its `subjectId` is still reported when the subject row exists), and both `resume-*` dispositions carry `subjectId`, `chatId` and `generation`. `ResolveLaunchResponseSchema` refuses every other combination
54
+ - `StartFreshChatResponse` is the NEW active generation and always links its predecessor (`continuationOf.chatId`)
55
+ - A launch posted into a chat that is not — or can no longer become — the subject's active generation is a 409 whose body is `LaunchTargetSuperseded` (`code: "launch_target_superseded"`); its `activeChatId` names where to go, and null means start a new chat
56
+ - Request bodies for resolve-launch and start-fresh are deliberately NOT here: the resolve body embeds the backend-local launch schema, and start-fresh has no body
57
+
44
58
  <!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
45
59
 
46
60
  ## Public API
47
61
 
48
62
  - `BaseEvent` _(type)_ — Base envelope for all SSE events.
49
63
  - `BaseEventSchema`
64
+ - `CHAT_CONTINUATION_REASONS` — Why a generation continues its predecessor.
50
65
  - `CHAT_DISCUSSION_REQUEST_TYPES` — The kinds of durable input request a turn can be scoped to.
51
66
  - `CHAT_PAGE_ENTITY_TYPES` — What kind of thing the reader has open, when they have one open.
52
67
  - `CHAT_PAGE_SCOPES` — The route parser's four URL scopes: unscoped, `/me/*`, `/team/*`, `/@:org/*`.
53
68
  - `CHAT_PAGE_VIEWS` — The app route parser's seventeen `type` values, projected onto the wire.
54
69
  - `CHAT_RUNTIME_PROFILES` — Ordered list of available profiles for UI rendering.
70
+ - `CONVERSATION_SUBJECT_KINDS` — The kinds of subject a chat can continue.
55
71
  - `ChatByInteractionResponse` _(type)_ — Response for GET /api/chats/by-interaction/:interactionId
56
72
  - `ChatByInteractionResponseSchema` — Response for GET /api/chats/by-interaction/:interactionId
57
73
  - `ChatChangedField` _(type)_ — Fields that can change on a chat, used in changed array.
58
74
  - `ChatChangedFieldSchema`
75
+ - `ChatContinuationReason` _(type)_
76
+ - `ChatContinuationReasonSchema`
77
+ - `ChatContinuity` _(type)_
78
+ - `ChatContinuitySchema` — Where a chat sits in its subject's lineage — carried on the chat detail.
59
79
  - `ChatCreatedEvent` _(type)_ — Emitted exactly once after a chat is successfully persisted.
60
80
  - `ChatCreatedEventSchema` — Emitted exactly once after a chat is persisted; a pushed proactive chat is announced over this event too.
61
81
  - `ChatDeletedEvent` _(type)_ — Emitted when a chat is deleted.
@@ -103,6 +123,8 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
103
123
  - `ChatUpdatedEventSchema`
104
124
  - `ChatVisibility` _(type)_ — Chat share visibility. - private: Token exists but access requires authentication as chat owner - public…
105
125
  - `ChatVisibilitySchema`
126
+ - `ConversationSubjectKind` _(type)_
127
+ - `ConversationSubjectKindSchema`
106
128
  - `CreateChatResponse` _(type)_ — Response for POST /api/chats
107
129
  - `CreateChatResponseSchema` — Response for POST /api/chats
108
130
  - `CreateShareRequest` _(type)_ — Request to create a new share for a chat.
@@ -117,15 +139,25 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
117
139
  - `InvalidateChatListEventSchema`
118
140
  - `InvalidationReason` _(type)_ — Reason for cache invalidation.
119
141
  - `InvalidationReasonSchema`
142
+ - `LAUNCH_DISPOSITIONS` — What the read-only launch resolution found for a hand-off.
143
+ - `LAUNCH_TARGET_SUPERSEDED_CODE` — The 409 code for a launch posted into a chat that is not the subject's active generation.
144
+ - `LaunchDisposition` _(type)_
145
+ - `LaunchDispositionSchema`
146
+ - `LaunchTargetSuperseded` _(type)_
147
+ - `LaunchTargetSupersededSchema` — The 409 body the authoritative launch write returns when the posted chat is not — or can no longer become —…
120
148
  - `ListSharesResponse` _(type)_ — Response for GET /api/chats/:chatId/shares
121
149
  - `ListSharesResponseSchema` — Response for GET /api/chats/:chatId/shares
122
150
  - `ProactiveChatResolution` _(type)_ — Resolving an occurrence id to the CURRENT reader's chat — one of the four bounded states `ready` (with…
123
151
  - `ProactiveChatResolutionSchema` — Resolving an occurrence id to THIS reader's chat (INV-PROACTIVE-LINK).
124
152
  - `RecognizedProactiveKind` _(type)_ — A wire `proactiveKind` after recognition: a kind this contracts version knows, or the raw string it did not —…
153
+ - `ResolveLaunchResponse` _(type)_
154
+ - `ResolveLaunchResponseSchema` — Response of the read-only launch resolution.
125
155
  - `SharedChatMessage` _(type)_ — Individual message in a shared chat view.
126
156
  - `SharedChatMessageSchema`
127
157
  - `SharedChatView` _(type)_ — Shared chat view returned to viewers.
128
158
  - `SharedChatViewSchema`
159
+ - `StartFreshChatResponse` _(type)_
160
+ - `StartFreshChatResponseSchema` — Response of the start-fresh write: the NEW active generation of the subject, linked back to the chat it…
129
161
  - `TitleGenerationRequest` _(type)_ — Request to generate a title for a chat.
130
162
  - `TitleGenerationResponse` _(type)_ — Response from title generation.
131
163
  - `TitleGenerationResponseSchema`
@@ -34,6 +34,7 @@ const BASE_DETAIL = {
34
34
  interactionId: "int_1",
35
35
  createdAt: "2026-08-31T00:00:00.000Z",
36
36
  updatedAt: "2026-08-31T01:00:00.000Z",
37
+ continuity: null,
37
38
  };
38
39
 
39
40
  describe("ChatDetail compaction boundary", () => {
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Chat continuity wire vocabulary (chat-continuity-wire-vocabulary).
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - The subject-kind and continuation-reason vocabularies are CLOSED and
6
+ * pinned member-for-member; the only continuation reason is
7
+ * `user-started-fresh`.
8
+ * - `generation` is a positive integer — generation 1 is the first.
9
+ * - `continuationOf` and `continuationReason` are null TOGETHER: a reason
10
+ * without a predecessor, or a predecessor without a reason, is refused.
11
+ * - `activeChatId` is nullable (the subject has no active generation).
12
+ * - `ChatDetail.continuity` is REQUIRED and NULLABLE: null parses (a chat
13
+ * with no subject), a missing key does not.
14
+ * - The launch dispositions are CLOSED. `created-and-launch` carries a null
15
+ * chatId AND generation (resolve never creates a chat); both `resume-*`
16
+ * dispositions carry subjectId, chatId and generation — the refinement
17
+ * rejects either direction of violation.
18
+ * - The start-fresh response always links its predecessor; the 409 body's
19
+ * code is the literal `launch_target_superseded`, activeChatId nullable.
20
+ * - Every export is reachable from both the chat barrel and the root barrel.
21
+ */
22
+ import { describe, expect, it } from "vitest";
23
+ import * as root from "../../index";
24
+ import {
25
+ CHAT_CONTINUATION_REASONS,
26
+ ChatContinuationReasonSchema,
27
+ ChatContinuitySchema,
28
+ CONVERSATION_SUBJECT_KINDS,
29
+ ConversationSubjectKindSchema,
30
+ LAUNCH_DISPOSITIONS,
31
+ LAUNCH_TARGET_SUPERSEDED_CODE,
32
+ LaunchDispositionSchema,
33
+ LaunchTargetSupersededSchema,
34
+ ResolveLaunchResponseSchema,
35
+ StartFreshChatResponseSchema,
36
+ } from "../continuity";
37
+ import * as chat from "../index";
38
+ import { ChatDetailSchema, GetChatResponseSchema } from "../schemas";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Fixtures
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Generation 1 of a subject, and the active one. */
45
+ const FIRST_GENERATION = {
46
+ subjectId: "subj_1",
47
+ subjectKind: "source-sync-candidate-review",
48
+ subjectTitle: "Review candidate files",
49
+ generation: 1,
50
+ isActiveGeneration: true,
51
+ activeChatId: "chat_1",
52
+ continuationOf: null,
53
+ continuationReason: null,
54
+ } as const;
55
+
56
+ /** Generation 2, continuing chat_1 because the reader started fresh. */
57
+ const SECOND_GENERATION = {
58
+ ...FIRST_GENERATION,
59
+ generation: 2,
60
+ activeChatId: "chat_2",
61
+ continuationOf: { chatId: "chat_1", createdAt: "2026-09-01T00:00:00.000Z" },
62
+ continuationReason: "user-started-fresh",
63
+ } as const;
64
+
65
+ /** A chat detail with every REQUIRED field except `continuity`. */
66
+ const BASE_DETAIL = {
67
+ id: "chat_1",
68
+ title: "Candidate review",
69
+ interactionId: "int_1",
70
+ createdAt: "2026-09-01T00:00:00.000Z",
71
+ updatedAt: "2026-09-01T01:00:00.000Z",
72
+ };
73
+
74
+ describe("continuity vocabularies", () => {
75
+ it("pins the closed subject kinds", () => {
76
+ expect([...CONVERSATION_SUBJECT_KINDS]).toEqual([
77
+ "source-sync-candidate-review",
78
+ "source-sync-synthesis-review",
79
+ "org-structure-review",
80
+ ]);
81
+ expect(new Set(CONVERSATION_SUBJECT_KINDS).size).toBe(
82
+ CONVERSATION_SUBJECT_KINDS.length,
83
+ );
84
+ expect(ConversationSubjectKindSchema.safeParse("chat").success).toBe(false);
85
+ });
86
+
87
+ it("has exactly one continuation reason: user-started-fresh", () => {
88
+ expect([...CHAT_CONTINUATION_REASONS]).toEqual(["user-started-fresh"]);
89
+ expect(ChatContinuationReasonSchema.safeParse("inactivity").success).toBe(
90
+ false,
91
+ );
92
+ expect(
93
+ ChatContinuationReasonSchema.safeParse("semantic-divergence").success,
94
+ ).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe("ChatContinuitySchema", () => {
99
+ it("a first generation has no continuation", () => {
100
+ const parsed = ChatContinuitySchema.parse(FIRST_GENERATION);
101
+ expect(parsed).toEqual(FIRST_GENERATION);
102
+ expect(parsed.generation).toBe(1);
103
+ expect(parsed.continuationOf).toBeNull();
104
+ expect(parsed.continuationReason).toBeNull();
105
+ });
106
+
107
+ it("parses a continuing generation with its predecessor and reason", () => {
108
+ expect(ChatContinuitySchema.parse(SECOND_GENERATION)).toEqual(
109
+ SECOND_GENERATION,
110
+ );
111
+ });
112
+
113
+ it("parses a superseded generation pointing at the active chat", () => {
114
+ const parsed = ChatContinuitySchema.parse({
115
+ ...FIRST_GENERATION,
116
+ isActiveGeneration: false,
117
+ activeChatId: "chat_2",
118
+ });
119
+ expect(parsed.activeChatId).toBe("chat_2");
120
+ });
121
+
122
+ it("accepts a null activeChatId when the subject has no active generation", () => {
123
+ const parsed = ChatContinuitySchema.parse({
124
+ ...FIRST_GENERATION,
125
+ isActiveGeneration: false,
126
+ activeChatId: null,
127
+ });
128
+ expect(parsed.activeChatId).toBeNull();
129
+ });
130
+
131
+ it("refuses a reason without a predecessor", () => {
132
+ expect(
133
+ ChatContinuitySchema.safeParse({
134
+ ...FIRST_GENERATION,
135
+ continuationReason: "user-started-fresh",
136
+ }).success,
137
+ ).toBe(false);
138
+ });
139
+
140
+ it("refuses a predecessor without a reason", () => {
141
+ expect(
142
+ ChatContinuitySchema.safeParse({
143
+ ...SECOND_GENERATION,
144
+ continuationReason: null,
145
+ }).success,
146
+ ).toBe(false);
147
+ });
148
+
149
+ it("requires a positive integer generation", () => {
150
+ for (const generation of [0, -1, 1.5]) {
151
+ expect(
152
+ ChatContinuitySchema.safeParse({ ...FIRST_GENERATION, generation })
153
+ .success,
154
+ ).toBe(false);
155
+ }
156
+ });
157
+
158
+ it("requires an ISO timestamp on the predecessor", () => {
159
+ expect(
160
+ ChatContinuitySchema.safeParse({
161
+ ...SECOND_GENERATION,
162
+ continuationOf: { chatId: "chat_1", createdAt: "yesterday" },
163
+ }).success,
164
+ ).toBe(false);
165
+ });
166
+
167
+ it("an unknown subject kind is rejected", () => {
168
+ expect(
169
+ ChatContinuitySchema.safeParse({
170
+ ...FIRST_GENERATION,
171
+ subjectKind: "org-structure/review",
172
+ }).success,
173
+ ).toBe(false);
174
+ });
175
+ });
176
+
177
+ describe("ChatDetail.continuity", () => {
178
+ it("is null for a chat that belongs to no subject", () => {
179
+ const parsed = ChatDetailSchema.parse({ ...BASE_DETAIL, continuity: null });
180
+ expect(parsed.continuity).toBeNull();
181
+ });
182
+
183
+ it("carries the lineage for a chat that belongs to a subject", () => {
184
+ const parsed = ChatDetailSchema.parse({
185
+ ...BASE_DETAIL,
186
+ continuity: SECOND_GENERATION,
187
+ });
188
+ expect(parsed.continuity?.generation).toBe(2);
189
+ });
190
+
191
+ it("a chat detail without continuity fails to parse", () => {
192
+ expect(ChatDetailSchema.safeParse(BASE_DETAIL).success).toBe(false);
193
+ });
194
+
195
+ it("rides the chat detail in GET /api/chats/:id", () => {
196
+ const parsed = GetChatResponseSchema.parse({
197
+ chat: { ...BASE_DETAIL, continuity: FIRST_GENERATION },
198
+ messages: [],
199
+ });
200
+ expect(parsed.chat.continuity?.subjectId).toBe("subj_1");
201
+ });
202
+ });
203
+
204
+ describe("barrel exports", () => {
205
+ it("every continuity value is reachable from both barrels", () => {
206
+ for (const barrel of [chat, root]) {
207
+ expect(barrel.CONVERSATION_SUBJECT_KINDS).toBe(
208
+ CONVERSATION_SUBJECT_KINDS,
209
+ );
210
+ expect(barrel.ConversationSubjectKindSchema).toBe(
211
+ ConversationSubjectKindSchema,
212
+ );
213
+ expect(barrel.CHAT_CONTINUATION_REASONS).toBe(CHAT_CONTINUATION_REASONS);
214
+ expect(barrel.ChatContinuationReasonSchema).toBe(
215
+ ChatContinuationReasonSchema,
216
+ );
217
+ expect(barrel.ChatContinuitySchema).toBe(ChatContinuitySchema);
218
+ expect(barrel.LAUNCH_DISPOSITIONS).toBe(LAUNCH_DISPOSITIONS);
219
+ expect(barrel.LaunchDispositionSchema).toBe(LaunchDispositionSchema);
220
+ expect(barrel.ResolveLaunchResponseSchema).toBe(
221
+ ResolveLaunchResponseSchema,
222
+ );
223
+ expect(barrel.StartFreshChatResponseSchema).toBe(
224
+ StartFreshChatResponseSchema,
225
+ );
226
+ expect(barrel.LAUNCH_TARGET_SUPERSEDED_CODE).toBe(
227
+ LAUNCH_TARGET_SUPERSEDED_CODE,
228
+ );
229
+ expect(barrel.LaunchTargetSupersededSchema).toBe(
230
+ LaunchTargetSupersededSchema,
231
+ );
232
+ }
233
+ });
234
+ });
235
+
236
+ describe("launch dispositions", () => {
237
+ it("pins the closed disposition vocabulary", () => {
238
+ expect([...LAUNCH_DISPOSITIONS]).toEqual([
239
+ "resume-existing-anchor",
240
+ "resume-and-launch",
241
+ "created-and-launch",
242
+ ]);
243
+ expect(new Set(LAUNCH_DISPOSITIONS).size).toBe(LAUNCH_DISPOSITIONS.length);
244
+ expect(LaunchDispositionSchema.safeParse("created").success).toBe(false);
245
+ });
246
+ });
247
+
248
+ describe("ResolveLaunchResponseSchema", () => {
249
+ const RESUME = {
250
+ subjectId: "subj_1",
251
+ chatId: "chat_1",
252
+ generation: 1,
253
+ } as const;
254
+
255
+ it("parses both resume dispositions with subject, chat and generation", () => {
256
+ for (const disposition of [
257
+ "resume-existing-anchor",
258
+ "resume-and-launch",
259
+ ] as const) {
260
+ expect(
261
+ ResolveLaunchResponseSchema.parse({ ...RESUME, disposition }),
262
+ ).toEqual({ ...RESUME, disposition });
263
+ }
264
+ });
265
+
266
+ it("parses created-and-launch with no chat and no generation", () => {
267
+ const parsed = ResolveLaunchResponseSchema.parse({
268
+ subjectId: null,
269
+ chatId: null,
270
+ generation: null,
271
+ disposition: "created-and-launch",
272
+ });
273
+ expect(parsed.chatId).toBeNull();
274
+ });
275
+
276
+ it("created-and-launch still reports an existing subject with no active generation", () => {
277
+ const parsed = ResolveLaunchResponseSchema.parse({
278
+ subjectId: "subj_1",
279
+ chatId: null,
280
+ generation: null,
281
+ disposition: "created-and-launch",
282
+ });
283
+ expect(parsed.subjectId).toBe("subj_1");
284
+ });
285
+
286
+ // Resolve never creates a chat, so a created-and-launch cannot name one.
287
+ it("created-and-launch with a chat id is rejected", () => {
288
+ expect(
289
+ ResolveLaunchResponseSchema.safeParse({
290
+ subjectId: "subj_1",
291
+ chatId: "chat_1",
292
+ generation: null,
293
+ disposition: "created-and-launch",
294
+ }).success,
295
+ ).toBe(false);
296
+ });
297
+
298
+ it("rejects created-and-launch carrying a generation", () => {
299
+ expect(
300
+ ResolveLaunchResponseSchema.safeParse({
301
+ subjectId: "subj_1",
302
+ chatId: null,
303
+ generation: 1,
304
+ disposition: "created-and-launch",
305
+ }).success,
306
+ ).toBe(false);
307
+ });
308
+
309
+ it("resume-and-launch without a chat id is rejected", () => {
310
+ const result = ResolveLaunchResponseSchema.safeParse({
311
+ ...RESUME,
312
+ chatId: null,
313
+ disposition: "resume-and-launch",
314
+ });
315
+ expect(result.success).toBe(false);
316
+ expect(result.error?.issues[0]?.path).toEqual(["chatId"]);
317
+ });
318
+
319
+ it("rejects a resume disposition missing chatId, subjectId or generation", () => {
320
+ for (const disposition of [
321
+ "resume-existing-anchor",
322
+ "resume-and-launch",
323
+ ] as const) {
324
+ for (const field of ["chatId", "subjectId", "generation"] as const) {
325
+ const result = ResolveLaunchResponseSchema.safeParse({
326
+ ...RESUME,
327
+ disposition,
328
+ [field]: null,
329
+ });
330
+ expect(result.success).toBe(false);
331
+ expect(result.error?.issues[0]?.path).toEqual([field]);
332
+ }
333
+ }
334
+ });
335
+
336
+ it("requires a positive integer generation", () => {
337
+ expect(
338
+ ResolveLaunchResponseSchema.safeParse({
339
+ ...RESUME,
340
+ generation: 0,
341
+ disposition: "resume-and-launch",
342
+ }).success,
343
+ ).toBe(false);
344
+ });
345
+ });
346
+
347
+ describe("StartFreshChatResponseSchema", () => {
348
+ const FRESH = {
349
+ chatId: "chat_2",
350
+ interactionId: "int_2",
351
+ subjectId: "subj_1",
352
+ generation: 2,
353
+ continuationOf: { chatId: "chat_1" },
354
+ };
355
+
356
+ it("parses the new active generation linked to its predecessor", () => {
357
+ expect(StartFreshChatResponseSchema.parse(FRESH)).toEqual(FRESH);
358
+ });
359
+
360
+ it("requires the predecessor link", () => {
361
+ expect(
362
+ StartFreshChatResponseSchema.safeParse({ ...FRESH, continuationOf: null })
363
+ .success,
364
+ ).toBe(false);
365
+ });
366
+
367
+ it("requires a positive integer generation", () => {
368
+ expect(
369
+ StartFreshChatResponseSchema.safeParse({ ...FRESH, generation: 0 })
370
+ .success,
371
+ ).toBe(false);
372
+ });
373
+ });
374
+
375
+ describe("LaunchTargetSupersededSchema", () => {
376
+ it("pins the 409 code", () => {
377
+ expect(LAUNCH_TARGET_SUPERSEDED_CODE).toBe("launch_target_superseded");
378
+ });
379
+
380
+ it("a superseded target names the active chat", () => {
381
+ const body = { code: "launch_target_superseded", activeChatId: "chat_2" };
382
+ expect(LaunchTargetSupersededSchema.parse(body)).toEqual(body);
383
+ });
384
+
385
+ it("a null activeChatId means start a new chat", () => {
386
+ expect(
387
+ LaunchTargetSupersededSchema.parse({
388
+ code: LAUNCH_TARGET_SUPERSEDED_CODE,
389
+ activeChatId: null,
390
+ }).activeChatId,
391
+ ).toBeNull();
392
+ });
393
+
394
+ it("rejects any other code", () => {
395
+ expect(
396
+ LaunchTargetSupersededSchema.safeParse({
397
+ code: "conflict",
398
+ activeChatId: null,
399
+ }).success,
400
+ ).toBe(false);
401
+ });
402
+ });
@@ -95,6 +95,7 @@ describe("ChatMessageSchema mentions", () => {
95
95
  interactionId: "int_1",
96
96
  createdAt: "2026-09-02T00:00:00.000Z",
97
97
  updatedAt: "2026-09-02T01:00:00.000Z",
98
+ continuity: null,
98
99
  },
99
100
  messages: [makeMessage({ mentions: [makeMention()] })],
100
101
  });
@@ -124,6 +124,7 @@ const BASE_DETAIL = {
124
124
  interactionId: "int_1",
125
125
  createdAt: ISO,
126
126
  updatedAt: ISO,
127
+ continuity: null,
127
128
  };
128
129
 
129
130
  describe("ChatMessageSchema author", () => {
@@ -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
@@ -589,6 +589,17 @@ export type {
589
589
  ChatParticipantRole,
590
590
  ChatParticipant,
591
591
  ChatParticipantsResponse,
592
+ // Chat continuity (chat-continuity-wire-vocabulary): the subject a chat
593
+ // belongs to, its generation, and the lineage back to its predecessor.
594
+ ConversationSubjectKind,
595
+ ChatContinuationReason,
596
+ ChatContinuity,
597
+ // Launch resolution: the disposition the read-only resolve reports, the
598
+ // start-fresh response, and the launch-target-superseded 409 body.
599
+ LaunchDisposition,
600
+ ResolveLaunchResponse,
601
+ StartFreshChatResponse,
602
+ LaunchTargetSuperseded,
592
603
  } from "./chat/index";
593
604
 
594
605
  export {
@@ -616,6 +627,21 @@ export {
616
627
  ChatParticipantRoleSchema,
617
628
  ChatParticipantSchema,
618
629
  ChatParticipantsResponseSchema,
630
+ // Chat continuity: the closed subject-kind and continuation-reason
631
+ // vocabularies, and the lineage object on the chat detail.
632
+ CONVERSATION_SUBJECT_KINDS,
633
+ ConversationSubjectKindSchema,
634
+ CHAT_CONTINUATION_REASONS,
635
+ ChatContinuationReasonSchema,
636
+ ChatContinuitySchema,
637
+ // Launch resolution: the closed disposition vocabulary, the resolve and
638
+ // start-fresh responses, and the 409 code + body for a superseded target.
639
+ LAUNCH_DISPOSITIONS,
640
+ LaunchDispositionSchema,
641
+ ResolveLaunchResponseSchema,
642
+ StartFreshChatResponseSchema,
643
+ LAUNCH_TARGET_SUPERSEDED_CODE,
644
+ LaunchTargetSupersededSchema,
619
645
  } from "./chat/index";
620
646
 
621
647
  // Organization domain types