@vellumai/assistant 0.11.2-dev.202608070442.84135c4 → 0.11.2-dev.202608070534.d55eaf1

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.
@@ -157,6 +157,8 @@ export {
157
157
  hashVerificationSecret,
158
158
  IdentityBindingStatusSchema,
159
159
  ResolveBootstrapSessionIpcParamsSchema,
160
+ bindsSameIdentity,
161
+ boundIdentity,
160
162
  RevokePendingSessionsIpcParamsSchema,
161
163
  SessionLookupIpcResponseSchema,
162
164
  SessionMutationIpcResponseSchema,
@@ -172,6 +174,7 @@ export {
172
174
 
173
175
  export type {
174
176
  BindSessionIdentityIpcParams,
177
+ BoundIdentity,
175
178
  CountRecentSendsIpcParams,
176
179
  CountRecentSendsIpcResponse,
177
180
  CreateInboundSessionIpcParams,
@@ -183,6 +186,7 @@ export type {
183
186
  FindActiveSessionIpcParams,
184
187
  GetPendingSessionIpcParams,
185
188
  IdentityBindingStatus,
189
+ IdentityBoundSession,
186
190
  ResolveBootstrapSessionIpcParams,
187
191
  RevokePendingSessionsIpcParams,
188
192
  SessionLookupIpcResponse,
@@ -113,6 +113,78 @@ export const VerificationSessionSchema = z.object({
113
113
 
114
114
  export type VerificationSessionWire = z.infer<typeof VerificationSessionSchema>;
115
115
 
116
+ // ---------------------------------------------------------------------------
117
+ // Bound identity (which of the three identity columns decides who may redeem)
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** The identity fields the rules read. Callers may pass a full session row. */
121
+ export interface IdentityBoundSession {
122
+ expectedExternalUserId?: string | null;
123
+ expectedChatId?: string | null;
124
+ expectedPhoneE164?: string | null;
125
+ }
126
+
127
+ /**
128
+ * The identity a session redeems on.
129
+ *
130
+ * `chatId` is compared against the actor's conversation and everything else
131
+ * against the actor themselves, which is why the field travels with the value.
132
+ */
133
+ export interface BoundIdentity {
134
+ field: "phoneE164" | "externalUserId" | "chatId";
135
+ value: string;
136
+ }
137
+
138
+ /**
139
+ * The one identity a session is bound to, or null when it is bound to none
140
+ * (an inbound challenge, or a bootstrap session before its deep link is
141
+ * redeemed).
142
+ *
143
+ * This lives in the contract because more than one side has to agree on it:
144
+ * the gateway consume path decides who may redeem a code, and the supersede
145
+ * scope decides whose earlier codes a fresh mint kills. Those two disagreeing
146
+ * is a silent one-time-code bug, so the rule is stated once.
147
+ *
148
+ * Precedence, most to least specific:
149
+ *
150
+ * 1. `expectedPhoneE164` (a voice session's subject is the number itself).
151
+ * 2. `expectedExternalUserId` (the person).
152
+ * 3. `expectedChatId` (the conversation), and only when nothing identifies a
153
+ * person. A chat id can be shared (a Slack channel, a Telegram group), so
154
+ * on its own it would let any participant satisfy the binding. It counts
155
+ * only where there is no better answer.
156
+ */
157
+ export function boundIdentity(
158
+ session: IdentityBoundSession,
159
+ ): BoundIdentity | null {
160
+ // Presence, not truthiness. An empty string is a set-but-useless identity,
161
+ // and treating it as absent would report the session unbound, which the
162
+ // consume path reads as "no identity to check" and admits anyone holding
163
+ // the code. Kept as a value it can compare against and fail instead.
164
+ if (session.expectedPhoneE164 != null) {
165
+ return { field: "phoneE164", value: session.expectedPhoneE164 };
166
+ }
167
+ if (session.expectedExternalUserId != null) {
168
+ return { field: "externalUserId", value: session.expectedExternalUserId };
169
+ }
170
+ if (session.expectedChatId != null) {
171
+ return { field: "chatId", value: session.expectedChatId };
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Whether two sessions are bound to the same identity. Two sessions bound to
178
+ * no identity are not the same: bootstrap sessions are claimed by id, so an
179
+ * unbound mint must not sweep up every other unbound row.
180
+ */
181
+ export function bindsSameIdentity(
182
+ a: BoundIdentity | null,
183
+ b: BoundIdentity | null,
184
+ ): boolean {
185
+ return a !== null && b !== null && a.field === b.field && a.value === b.value;
186
+ }
187
+
116
188
  // ---------------------------------------------------------------------------
117
189
  // IPC methods
118
190
  // ---------------------------------------------------------------------------
@@ -193,9 +265,12 @@ export type CreateInboundSessionIpcResponse = z.infer<
193
265
  /** Request for `verification_sessions_create_outbound`. */
194
266
  export const CreateOutboundSessionIpcParamsSchema = z.object({
195
267
  channel: z.string().min(1),
196
- expectedExternalUserId: z.string().optional(),
197
- expectedChatId: z.string().optional(),
198
- expectedPhoneE164: z.string().optional(),
268
+ // An identity field is either absent or a real value. An empty string is
269
+ // neither, and a session bound to one can never be redeemed by anybody,
270
+ // so reject it at the boundary rather than mint a dead code.
271
+ expectedExternalUserId: z.string().min(1).optional(),
272
+ expectedChatId: z.string().min(1).optional(),
273
+ expectedPhoneE164: z.string().min(1).optional(),
199
274
  identityBindingStatus: IdentityBindingStatusSchema.optional(),
200
275
  destinationAddress: z.string().optional(),
201
276
  codeDigits: z.number().int().positive().optional(),
package/openapi.yaml CHANGED
@@ -9293,8 +9293,10 @@ paths:
9293
9293
  /v1/conversations/reorder:
9294
9294
  post:
9295
9295
  operationId: conversations_reorder_post
9296
- summary: Reorder conversations
9297
- description: Batch-update display order and pin state for conversations.
9296
+ summary: Move conversations between sections
9297
+ description:
9298
+ Batch-update which group holds each conversation, and its pin state. Lists are ordered by recency, so this
9299
+ sets placement, not order.
9298
9300
  tags:
9299
9301
  - conversations
9300
9302
  requestBody:
@@ -9311,8 +9313,6 @@ paths:
9311
9313
  properties:
9312
9314
  conversationId:
9313
9315
  type: string
9314
- displayOrder:
9315
- type: number
9316
9316
  isPinned:
9317
9317
  type: boolean
9318
9318
  groupId:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608070442.84135c4",
3
+ "version": "0.11.2-dev.202608070534.d55eaf1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -61,7 +61,7 @@ mock.module("../persistence/conversation-crud.js", () => ({
61
61
  getConversationPersistedSeq: () => null,
62
62
  addMessage: mock(() => ({ id: "msg-1" })),
63
63
  archiveConversation: mock(() => true),
64
- batchSetDisplayOrders: mock(() => {}),
64
+ batchSetConversationPlacement: mock(() => {}),
65
65
  createConversation: (opts: { conversationType: string }) => {
66
66
  createdConversations.push(opts);
67
67
  return { id: "conv-1", ...opts };
@@ -1782,26 +1782,46 @@ describe("outbound verification sessions", () => {
1782
1782
  expect(result.success).toBe(true);
1783
1783
  });
1784
1784
 
1785
- // ── Voice identity match via expectedExternalUserId ──
1785
+ // ── Voice identity is the number ──
1786
1786
 
1787
- test("Voice identity match succeeds via expectedExternalUserId", async () => {
1787
+ test("a voice session is redeemed by the number it was sent to", async () => {
1788
+ // Voice knows the caller by one thing, so the consume path receives that
1789
+ // number as both actor axes. Every voice mint records it in both
1790
+ // identity columns, and either way the session is bound to the number.
1788
1791
  const { secret } = await createOutboundSession({
1789
1792
  channel: "phone",
1790
- expectedExternalUserId: "voice-user-42",
1791
- expectedPhoneE164: "+15551234567",
1792
- destinationAddress: "+15551234567",
1793
+ expectedExternalUserId: "+15555550101",
1794
+ expectedPhoneE164: "+15555550101",
1795
+ destinationAddress: "+15555550101",
1793
1796
  });
1794
1797
 
1795
- // Actor matches expectedExternalUserId
1796
1798
  const result = await validateAndConsumeVerification(
1797
1799
  "phone",
1798
1800
  secret,
1799
- "voice-user-42",
1800
- "voice-chat-1",
1801
+ "+15555550101",
1802
+ "+15555550101",
1801
1803
  );
1802
1804
 
1803
1805
  expect(result.success).toBe(true);
1804
1806
  });
1807
+
1808
+ test("a voice session refuses a caller on a different number", async () => {
1809
+ const { secret } = await createOutboundSession({
1810
+ channel: "phone",
1811
+ expectedExternalUserId: "+15555550101",
1812
+ expectedPhoneE164: "+15555550101",
1813
+ destinationAddress: "+15555550101",
1814
+ });
1815
+
1816
+ const result = await validateAndConsumeVerification(
1817
+ "phone",
1818
+ secret,
1819
+ "+15555550102",
1820
+ "+15555550102",
1821
+ );
1822
+
1823
+ expect(result.success).toBe(false);
1824
+ });
1805
1825
  });
1806
1826
 
1807
1827
  // ═══════════════════════════════════════════════════════════════════════════
@@ -17,7 +17,7 @@
17
17
  import { beforeEach, describe, expect, test } from "bun:test";
18
18
 
19
19
  import {
20
- batchSetDisplayOrders,
20
+ batchSetConversationPlacement,
21
21
  createConversation,
22
22
  } from "../persistence/conversation-crud.js";
23
23
  import { listConversations } from "../persistence/conversation-queries.js";
@@ -58,22 +58,16 @@ describe("pinning a background conversation", () => {
58
58
  const conv = seedRoutedBackground("bg-job", "background");
59
59
  expect(visibleTitles()).toEqual([]);
60
60
 
61
- batchSetDisplayOrders([
62
- { id: conv.id, displayOrder: 0, groupId: "system:pinned" },
63
- ]);
61
+ batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
64
62
 
65
63
  expect(titlesInGroup("system:pinned")).toEqual(["bg-job"]);
66
64
  });
67
65
 
68
66
  test("unpinning lands it in Recents rather than hiding it again", () => {
69
67
  const conv = seedRoutedBackground("bg-job", "background");
70
- batchSetDisplayOrders([
71
- { id: conv.id, displayOrder: 0, groupId: "system:pinned" },
72
- ]);
68
+ batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
73
69
 
74
- batchSetDisplayOrders([
75
- { id: conv.id, displayOrder: null, groupId: "system:all" },
76
- ]);
70
+ batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
77
71
 
78
72
  expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
79
73
  });
@@ -84,9 +78,7 @@ describe("filing a background conversation into a custom group", () => {
84
78
  const group = createGroup("Car Chat");
85
79
  const conv = seedRoutedBackground("bg-job", "background");
86
80
 
87
- batchSetDisplayOrders([
88
- { id: conv.id, displayOrder: 0, groupId: group.id },
89
- ]);
81
+ batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
90
82
 
91
83
  expect(titlesInGroup(group.id)).toEqual(["bg-job"]);
92
84
  });
@@ -94,13 +86,9 @@ describe("filing a background conversation into a custom group", () => {
94
86
  test("removing it from the group lands it in Recents rather than losing it", () => {
95
87
  const group = createGroup("Car Chat");
96
88
  const conv = seedRoutedBackground("bg-job", "background");
97
- batchSetDisplayOrders([
98
- { id: conv.id, displayOrder: 0, groupId: group.id },
99
- ]);
89
+ batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
100
90
 
101
- batchSetDisplayOrders([
102
- { id: conv.id, displayOrder: null, groupId: "system:all" },
103
- ]);
91
+ batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
104
92
 
105
93
  expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
106
94
  expect(visibleTitles()).toEqual(["bg-job"]);
@@ -110,12 +98,8 @@ describe("filing a background conversation into a custom group", () => {
110
98
  const group = createGroup("Morning Briefs");
111
99
  const conv = seedRoutedBackground("sched-job", "scheduled");
112
100
 
113
- batchSetDisplayOrders([
114
- { id: conv.id, displayOrder: 0, groupId: group.id },
115
- ]);
116
- batchSetDisplayOrders([
117
- { id: conv.id, displayOrder: null, groupId: "system:all" },
118
- ]);
101
+ batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
102
+ batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
119
103
 
120
104
  expect(titlesInGroup("system:all")).toEqual(["sched-job"]);
121
105
  });
@@ -125,13 +109,11 @@ describe("what promotion must not do", () => {
125
109
  test("filing into system:background still demotes", () => {
126
110
  const conv = seedRoutedBackground("bg-job", "background");
127
111
  // Promote first, so the demotion has something to undo.
128
- batchSetDisplayOrders([
129
- { id: conv.id, displayOrder: 0, groupId: "system:pinned" },
130
- ]);
112
+ batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
131
113
  expect(titlesInGroup("system:pinned")).toEqual(["bg-job"]);
132
114
 
133
- batchSetDisplayOrders([
134
- { id: conv.id, displayOrder: null, groupId: "system:background" },
115
+ batchSetConversationPlacement([
116
+ { id: conv.id, groupId: "system:background" },
135
117
  ]);
136
118
 
137
119
  expect(visibleTitles()).toEqual([]);
@@ -149,9 +131,7 @@ describe("what promotion must not do", () => {
149
131
  // system bucket.
150
132
  const conv = seedRoutedBackground("bg-job", "background");
151
133
 
152
- batchSetDisplayOrders([
153
- { id: conv.id, displayOrder: null, groupId: "system:all" },
154
- ]);
134
+ batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
155
135
 
156
136
  expect(visibleTitles()).toEqual([]);
157
137
  });
@@ -168,13 +148,9 @@ describe("what promotion must not do", () => {
168
148
  // came from, `surfaced_at` records that the user promoted it, and only
169
149
  // an explicit demotion clears the latter.
170
150
  const conv = seedRoutedBackground("bg-job", "background");
171
- batchSetDisplayOrders([
172
- { id: conv.id, displayOrder: 0, groupId: "system:pinned" },
173
- ]);
151
+ batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
174
152
 
175
- batchSetDisplayOrders([
176
- { id: conv.id, displayOrder: null, isPinned: false },
177
- ]);
153
+ batchSetConversationPlacement([{ id: conv.id, isPinned: false }]);
178
154
 
179
155
  expect(visibleTitles()).toEqual(["bg-job"]);
180
156
  expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
@@ -186,9 +162,7 @@ describe("what promotion must not do", () => {
186
162
  const group = createGroup("Car Chat");
187
163
  const conv = createConversation("plain-chat");
188
164
 
189
- batchSetDisplayOrders([
190
- { id: conv.id, displayOrder: 0, groupId: group.id },
191
- ]);
165
+ batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
192
166
 
193
167
  const row = getDb()
194
168
  .all(`SELECT surfaced_at FROM conversations WHERE id = '${conv.id}'`)
@@ -74,7 +74,7 @@ const getConversationMock = mock((id: string) =>
74
74
  mock.module("../persistence/conversation-crud.js", () => ({
75
75
  addMessage: async () => ({ id: "unused", deduplicated: false }),
76
76
  archiveConversation: () => true,
77
- batchSetDisplayOrders: () => {},
77
+ batchSetConversationPlacement: () => {},
78
78
  countConversationsByScheduleJobId: () => 0,
79
79
  deleteConversation: () => ({ segmentIds: [], deletedSummaryIds: [] }),
80
80
  extractImageSourcePaths: () => undefined,
@@ -53,7 +53,7 @@ const getConversationMock = mock((id: string) =>
53
53
  mock.module("../persistence/conversation-crud.js", () => ({
54
54
  addMessage: addMessageMock,
55
55
  archiveConversation: () => true,
56
- batchSetDisplayOrders: () => {},
56
+ batchSetConversationPlacement: () => {},
57
57
  countConversationsByScheduleJobId: () => 0,
58
58
  deleteConversation: () => ({ segmentIds: [], deletedSummaryIds: [] }),
59
59
  extractImageSourcePaths: () => undefined,
@@ -22,6 +22,8 @@
22
22
  import { randomBytes, randomUUID } from "node:crypto";
23
23
 
24
24
  import {
25
+ bindsSameIdentity,
26
+ boundIdentity,
25
27
  hashVerificationSecret,
26
28
  VERIFICATION_SESSIONS_IPC_METHODS,
27
29
  type VerificationSessionWire,
@@ -64,30 +66,11 @@ function isInterceptable(s: VerificationSessionWire): boolean {
64
66
 
65
67
  const OUTBOUND_LIVE_STATUSES = ["pending_bootstrap", "awaiting_response"];
66
68
 
67
- /**
68
- * The identity a session redeems on, in `checkIdentityMatch`'s precedence
69
- * order. Null when the session carries none, which is a bootstrap session.
70
- */
71
- function boundIdentity(session: {
72
- expectedExternalUserId?: string | null;
73
- expectedChatId?: string | null;
74
- expectedPhoneE164?: string | null;
75
- }): string | null {
76
- if (session.expectedExternalUserId) {
77
- return `user:${session.expectedExternalUserId}`;
78
- }
79
- if (session.expectedPhoneE164) {
80
- return `phone:${session.expectedPhoneE164}`;
81
- }
82
- if (session.expectedChatId) {
83
- return `chat:${session.expectedChatId}`;
84
- }
85
- return null;
86
- }
87
-
88
69
  /**
89
70
  * Mirrors the gateway store: an outbound mint supersedes only sessions bound
90
71
  * to the same identity, leaving other actors and inbound challenges alone.
72
+ * The identity rule itself comes from the shared contract, so this cannot
73
+ * drift from what the gateway does.
91
74
  */
92
75
  function revokeSameActorOutbound(
93
76
  channel: string,
@@ -102,7 +85,7 @@ function revokeSameActorOutbound(
102
85
  if (
103
86
  s.channel === channel &&
104
87
  OUTBOUND_LIVE_STATUSES.includes(s.status) &&
105
- boundIdentity(s) === identity
88
+ bindsSameIdentity(boundIdentity(s), identity)
106
89
  ) {
107
90
  s.status = "revoked";
108
91
  s.updatedAt = now;
@@ -522,50 +505,24 @@ function recordInvalidAttempt(key: string): void {
522
505
  rateLimits.set(key, entry);
523
506
  }
524
507
 
508
+ /**
509
+ * Mirrors the gateway consume path. Both sides read the shared
510
+ * `boundIdentity`, so the sim cannot accept a code the real service would
511
+ * refuse. A session bound to no identity, or not yet finally bound
512
+ * (`pending_bootstrap`), bypasses the check as it does at the gateway.
513
+ */
525
514
  function checkIdentityMatch(
526
515
  session: VerificationSessionWire,
527
516
  actorExternalUserId: string,
528
517
  actorChatId: string,
529
518
  ): boolean {
530
- const hasExpectedIdentity =
531
- session.expectedExternalUserId != null ||
532
- session.expectedChatId != null ||
533
- session.expectedPhoneE164 != null;
534
- // pending_bootstrap (and unbound inbound) sessions bypass the check.
535
- if (!hasExpectedIdentity || session.identityBindingStatus !== "bound") {
536
- return true;
537
- }
538
-
539
- if (
540
- session.expectedPhoneE164 != null &&
541
- (actorExternalUserId === session.expectedPhoneE164 ||
542
- actorExternalUserId === session.expectedExternalUserId)
543
- ) {
544
- return true;
545
- }
546
-
547
- // Shared-chatId caveat: when both expected fields are set, require the
548
- // externalUserId match — chat IDs can be shared.
549
- if (session.expectedChatId != null) {
550
- if (session.expectedExternalUserId != null) {
551
- if (actorExternalUserId === session.expectedExternalUserId) {
552
- return true;
553
- }
554
- } else if (actorChatId === session.expectedChatId) {
555
- return true;
556
- }
557
- }
558
-
559
- if (
560
- session.expectedPhoneE164 == null &&
561
- session.expectedChatId == null &&
562
- session.expectedExternalUserId != null &&
563
- actorExternalUserId === session.expectedExternalUserId
564
- ) {
519
+ const identity = boundIdentity(session);
520
+ if (identity === null || session.identityBindingStatus !== "bound") {
565
521
  return true;
566
522
  }
567
-
568
- return false;
523
+ return identity.field === "chatId"
524
+ ? actorChatId === identity.value
525
+ : actorExternalUserId === identity.value;
569
526
  }
570
527
 
571
528
  export function validateAndConsumeVerification(
@@ -13,7 +13,7 @@ mock.module("../config/env.js", () => ({
13
13
  }));
14
14
 
15
15
  import {
16
- batchSetDisplayOrders,
16
+ batchSetConversationPlacement,
17
17
  createConversation,
18
18
  updateConversationTitle,
19
19
  } from "../persistence/conversation-crud.js";
@@ -100,9 +100,7 @@ describe("conversation lineage in HTTP reads", () => {
100
100
 
101
101
  test("GET /v1/conversations/:id includes pin metadata when present", async () => {
102
102
  const conversation = createConversation("Pinned conversation");
103
- batchSetDisplayOrders([
104
- { id: conversation.id, displayOrder: 7, isPinned: true },
105
- ]);
103
+ batchSetConversationPlacement([{ id: conversation.id, isPinned: true }]);
106
104
  await startServer();
107
105
 
108
106
  const response = await fetch(url(`/conversations/${conversation.id}`));
@@ -115,7 +113,6 @@ describe("conversation lineage in HTTP reads", () => {
115
113
  expect(body.conversation).toMatchObject({
116
114
  id: conversation.id,
117
115
  title: conversation.title ?? "Untitled",
118
- displayOrder: 7,
119
116
  isPinned: true,
120
117
  });
121
118
  });
@@ -4209,13 +4209,19 @@ export function getConversationRecentProvenanceTrustClass(
4209
4209
  }
4210
4210
 
4211
4211
  // ---------------------------------------------------------------------------
4212
- // CRUD functions for display_order and is_pinned
4212
+ // Conversation placement (group + pin) and display metadata
4213
4213
  // ---------------------------------------------------------------------------
4214
4214
 
4215
- export function batchSetDisplayOrders(
4215
+ /**
4216
+ * Move conversations between sections: set the group, and the pin state
4217
+ * derived from it.
4218
+ *
4219
+ * Writes no ordering. Every list is ordered by recency, so a conversation's
4220
+ * placement is which section holds it, not where it sits inside one.
4221
+ */
4222
+ export function batchSetConversationPlacement(
4216
4223
  updates: Array<{
4217
4224
  id: string;
4218
- displayOrder: number | null;
4219
4225
  isPinned?: boolean;
4220
4226
  groupId?: string | null;
4221
4227
  }>,
@@ -4236,7 +4242,7 @@ export function batchSetDisplayOrders(
4236
4242
  safeGroupId = UNGROUPED_GROUP_ID;
4237
4243
  } else if (
4238
4244
  !rawGet<{ id: string }>(
4239
- "conversation:batchSetDisplayOrders:groupCheck",
4245
+ "conversation:batchSetConversationPlacement:groupCheck",
4240
4246
  "SELECT id FROM conversation_groups WHERE id = ?",
4241
4247
  safeGroupId,
4242
4248
  )
@@ -4270,13 +4276,8 @@ export function batchSetDisplayOrders(
4270
4276
  safeGroupId !== UNGROUPED_GROUP_ID &&
4271
4277
  (safeGroupId === PINNED_GROUP_ID ||
4272
4278
  !safeGroupId.startsWith("system:"));
4273
- const setClauses = [
4274
- "display_order = ?",
4275
- "is_pinned = ?",
4276
- "group_id = ?",
4277
- ];
4279
+ const setClauses = ["is_pinned = ?", "group_id = ?"];
4278
4280
  const params: Array<string | number | null> = [
4279
- update.displayOrder,
4280
4281
  safeGroupId === PINNED_GROUP_ID ? 1 : 0,
4281
4282
  safeGroupId,
4282
4283
  ];
@@ -4291,31 +4292,22 @@ export function batchSetDisplayOrders(
4291
4292
  }
4292
4293
  params.push(update.id);
4293
4294
  rawRun(
4294
- "conversation:batchSetDisplayOrders:group",
4295
+ "conversation:batchSetConversationPlacement:group",
4295
4296
  `UPDATE conversations SET ${setClauses.join(", ")} WHERE id = ?`,
4296
4297
  ...params,
4297
4298
  );
4298
- } else if (update.isPinned === undefined) {
4299
- // Only displayOrder provided — preserve existing pin state and group.
4300
- rawRun(
4301
- "conversation:batchSetDisplayOrders:orderOnly",
4302
- "UPDATE conversations SET display_order = ? WHERE id = ?",
4303
- update.displayOrder,
4304
- update.id,
4305
- );
4306
- } else if (update.isPinned) {
4299
+ } else if (update.isPinned === true) {
4307
4300
  rawRun(
4308
- "conversation:batchSetDisplayOrders:pin",
4309
- "UPDATE conversations SET display_order = ?, is_pinned = 1, group_id = 'system:pinned' WHERE id = ?",
4310
- update.displayOrder,
4301
+ "conversation:batchSetConversationPlacement:pin",
4302
+ "UPDATE conversations SET is_pinned = 1, group_id = 'system:pinned' WHERE id = ?",
4311
4303
  update.id,
4312
4304
  );
4313
- } else {
4305
+ } else if (update.isPinned === false) {
4314
4306
  // Restore system group from source/conversationType when unpinning,
4315
4307
  // instead of clearing to NULL (which would lose provenance).
4316
4308
  rawRun(
4317
- "conversation:batchSetDisplayOrders:unpin",
4318
- `UPDATE conversations SET display_order = ?, is_pinned = 0,
4309
+ "conversation:batchSetConversationPlacement:unpin",
4310
+ `UPDATE conversations SET is_pinned = 0,
4319
4311
  group_id = CASE WHEN group_id = 'system:pinned' THEN
4320
4312
  CASE
4321
4313
  WHEN source IN ('schedule', 'reminder') THEN 'system:scheduled'
@@ -4325,7 +4317,6 @@ export function batchSetDisplayOrders(
4325
4317
  END
4326
4318
  ELSE group_id END
4327
4319
  WHERE id = ?`,
4328
- update.displayOrder,
4329
4320
  update.id,
4330
4321
  );
4331
4322
  }
@@ -36,7 +36,7 @@ export function _resetGroupMigrationForTests(): void {
36
36
  * for the first time inside a Drizzle db.transaction() block — SQLite
37
37
  * does not support nested transactions. In practice this is safe because
38
38
  * the migration is triggered by early startup queries (listConversations,
39
- * batchSetDisplayOrders) before any transaction-wrapped paths run, and
39
+ * batchSetConversationPlacement) before any transaction-wrapped paths run, and
40
40
  * the `migrated` flag makes subsequent calls no-ops.
41
41
  */
42
42
  export function ensureGroupMigration(): void {
@@ -46,7 +46,7 @@ import { normalizeConversationType } from "../../daemon/message-types/shared.js"
46
46
  import { stripConversationIds } from "../../home/feed-writer.js";
47
47
  import {
48
48
  archiveConversation,
49
- batchSetDisplayOrders,
49
+ batchSetConversationPlacement,
50
50
  countConversationsByScheduleJobId,
51
51
  deleteConversation,
52
52
  forkConversation as forkConversationInStore,
@@ -845,7 +845,6 @@ function handleReorderConversations({ body = {}, headers }: RouteHandlerArgs) {
845
845
  const updates = body.updates as
846
846
  | Array<{
847
847
  conversationId: string;
848
- displayOrder?: number;
849
848
  isPinned?: boolean;
850
849
  groupId?: string | null;
851
850
  }>
@@ -853,10 +852,9 @@ function handleReorderConversations({ body = {}, headers }: RouteHandlerArgs) {
853
852
  if (!Array.isArray(updates)) {
854
853
  throw new BadRequestError("Missing updates array");
855
854
  }
856
- batchSetDisplayOrders(
855
+ batchSetConversationPlacement(
857
856
  updates.map((u) => ({
858
857
  id: u.conversationId,
859
- displayOrder: u.displayOrder ?? null,
860
858
  isPinned: u.isPinned,
861
859
  groupId: u.groupId,
862
860
  })),
@@ -1304,14 +1302,15 @@ export const ROUTES: RouteDefinition[] = [
1304
1302
  requiredScopes: ["chat.write"],
1305
1303
  allowedPrincipalTypes: ACTOR_PRINCIPALS,
1306
1304
  },
1307
- summary: "Reorder conversations",
1308
- description: "Batch-update display order and pin state for conversations.",
1305
+ summary: "Move conversations between sections",
1306
+ description:
1307
+ "Batch-update which group holds each conversation, and its pin state. " +
1308
+ "Lists are ordered by recency, so this sets placement, not order.",
1309
1309
  tags: ["conversations"],
1310
1310
  requestBody: z.object({
1311
1311
  updates: z.array(
1312
1312
  z.object({
1313
1313
  conversationId: z.string(),
1314
- displayOrder: z.number().optional(),
1315
1314
  isPinned: z.boolean().optional(),
1316
1315
  groupId: z.string().nullable().optional(),
1317
1316
  }),
@@ -920,14 +920,15 @@ export async function resendOutbound(
920
920
  };
921
921
  }
922
922
 
923
- const resendDestination =
923
+ const destination =
924
924
  session.destinationAddress ??
925
925
  session.expectedPhoneE164 ??
926
926
  session.expectedChatId;
927
- if (resendDestination) {
927
+
928
+ if (destination) {
928
929
  const recentDestSends = await countRecentSendsToDestination(
929
930
  channel,
930
- resendDestination,
931
+ destination,
931
932
  DESTINATION_RATE_WINDOW_MS,
932
933
  );
933
934
  if (recentDestSends >= MAX_SENDS_PER_DESTINATION_WINDOW) {
@@ -941,10 +942,6 @@ export async function resendOutbound(
941
942
  }
942
943
  }
943
944
 
944
- const destination =
945
- session.destinationAddress ??
946
- session.expectedPhoneE164 ??
947
- session.expectedChatId;
948
945
  if (!destination) {
949
946
  return {
950
947
  success: false,
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  import {
4
- batchSetDisplayOrders,
4
+ batchSetConversationPlacement,
5
5
  getConversation,
6
6
  getDisplayMetaForConversations,
7
7
  } from "../../persistence/conversation-crud.js";
@@ -73,15 +73,7 @@ export async function executeConversationMoveToGroup(
73
73
  };
74
74
  }
75
75
 
76
- // Preserve the conversation's manual sort slot across the move; only the
77
- // group assignment changes.
78
- batchSetDisplayOrders([
79
- {
80
- id: conversationId,
81
- displayOrder: meta?.displayOrder ?? null,
82
- groupId: group.id,
83
- },
84
- ]);
76
+ batchSetConversationPlacement([{ id: conversationId, groupId: group.id }]);
85
77
  publishConversationListAndMetadataChanged("reordered", [conversationId]);
86
78
 
87
79
  const notes: string[] = [];