@vellumai/assistant 0.4.4 → 0.4.6

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.
Files changed (90) hide show
  1. package/ARCHITECTURE.md +4 -4
  2. package/README.md +6 -6
  3. package/bun.lock +6 -2
  4. package/docs/architecture/memory.md +4 -4
  5. package/package.json +2 -2
  6. package/src/__tests__/actor-token-service.test.ts +5 -2
  7. package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
  8. package/src/__tests__/call-controller.test.ts +78 -0
  9. package/src/__tests__/call-domain.test.ts +148 -10
  10. package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
  11. package/src/__tests__/call-pointer-messages.test.ts +105 -43
  12. package/src/__tests__/canonical-guardian-store.test.ts +44 -10
  13. package/src/__tests__/channel-approval-routes.test.ts +67 -65
  14. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
  15. package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
  16. package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
  17. package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
  18. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
  19. package/src/__tests__/guardian-grant-minting.test.ts +24 -24
  20. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
  21. package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
  22. package/src/__tests__/guardian-routing-state.test.ts +4 -4
  23. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
  24. package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
  25. package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
  26. package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
  27. package/src/__tests__/non-member-access-request.test.ts +50 -47
  28. package/src/__tests__/relay-server.test.ts +71 -0
  29. package/src/__tests__/send-endpoint-busy.test.ts +6 -0
  30. package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
  31. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
  32. package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
  33. package/src/__tests__/system-prompt.test.ts +1 -0
  34. package/src/__tests__/tool-approval-handler.test.ts +1 -1
  35. package/src/__tests__/tool-grant-request-escalation.test.ts +9 -2
  36. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +8 -1
  37. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +22 -22
  38. package/src/__tests__/trusted-contact-multichannel.test.ts +4 -4
  39. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  40. package/src/approvals/guardian-decision-primitive.ts +29 -25
  41. package/src/approvals/guardian-request-resolvers.ts +9 -5
  42. package/src/calls/call-pointer-message-composer.ts +27 -85
  43. package/src/calls/call-pointer-messages.ts +54 -21
  44. package/src/calls/guardian-dispatch.ts +30 -0
  45. package/src/calls/relay-server.ts +13 -13
  46. package/src/config/system-prompt.ts +10 -3
  47. package/src/config/templates/BOOTSTRAP.md +6 -5
  48. package/src/config/templates/USER.md +1 -0
  49. package/src/config/user-reference.ts +44 -0
  50. package/src/daemon/handlers/guardian-actions.ts +5 -2
  51. package/src/daemon/handlers/sessions.ts +8 -3
  52. package/src/daemon/lifecycle.ts +109 -3
  53. package/src/daemon/server.ts +32 -24
  54. package/src/daemon/session-agent-loop.ts +4 -3
  55. package/src/daemon/session-lifecycle.ts +1 -9
  56. package/src/daemon/session-process.ts +2 -2
  57. package/src/daemon/session-runtime-assembly.ts +2 -0
  58. package/src/daemon/session-tool-setup.ts +10 -0
  59. package/src/daemon/session.ts +1 -0
  60. package/src/memory/canonical-guardian-store.ts +40 -0
  61. package/src/memory/conversation-crud.ts +26 -0
  62. package/src/memory/conversation-store.ts +1 -0
  63. package/src/memory/db-init.ts +8 -0
  64. package/src/memory/guardian-bindings.ts +4 -0
  65. package/src/memory/job-handlers/backfill.ts +2 -9
  66. package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
  67. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
  68. package/src/memory/migrations/index.ts +2 -0
  69. package/src/memory/migrations/registry.ts +5 -0
  70. package/src/memory/schema.ts +3 -0
  71. package/src/notifications/copy-composer.ts +2 -2
  72. package/src/runtime/access-request-helper.ts +43 -28
  73. package/src/runtime/actor-trust-resolver.ts +19 -14
  74. package/src/runtime/channel-guardian-service.ts +6 -0
  75. package/src/runtime/guardian-context-resolver.ts +6 -2
  76. package/src/runtime/guardian-reply-router.ts +33 -16
  77. package/src/runtime/guardian-vellum-migration.ts +29 -5
  78. package/src/runtime/http-types.ts +0 -13
  79. package/src/runtime/local-actor-identity.ts +19 -13
  80. package/src/runtime/middleware/actor-token.ts +2 -2
  81. package/src/runtime/routes/channel-delivery-routes.ts +5 -5
  82. package/src/runtime/routes/conversation-routes.ts +45 -35
  83. package/src/runtime/routes/guardian-action-routes.ts +7 -1
  84. package/src/runtime/routes/guardian-approval-interception.ts +52 -52
  85. package/src/runtime/routes/guardian-bootstrap-routes.ts +1 -0
  86. package/src/runtime/routes/inbound-conversation.ts +7 -7
  87. package/src/runtime/routes/inbound-message-handler.ts +105 -94
  88. package/src/runtime/tool-grant-request-helper.ts +1 -0
  89. package/src/util/logger.ts +10 -0
  90. package/src/daemon/call-pointer-generators.ts +0 -59
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
 
5
5
  import { config as dotenvConfig } from 'dotenv';
6
6
 
7
- import { setPointerCopyGenerator } from '../calls/call-pointer-messages.js';
7
+ import { setPointerMessageProcessor } from '../calls/call-pointer-messages.js';
8
8
  import { reconcileCallsOnStartup } from '../calls/call-recovery.js';
9
9
  import { setRelayBroadcast } from '../calls/relay-server.js';
10
10
  import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
@@ -53,7 +53,6 @@ import {
53
53
  import { listWorkItems, updateWorkItem } from '../work-items/work-item-store.js';
54
54
  import { WorkspaceHeartbeatService } from '../workspace/heartbeat-service.js';
55
55
  import { createApprovalConversationGenerator,createApprovalCopyGenerator } from './approval-generators.js';
56
- import { createPointerCopyGenerator } from './call-pointer-generators.js';
57
56
  import { hasNoAuthOverride, hasUngatedNoAuthOverride } from './connection-policy.js';
58
57
  import { cleanupPidFile, cleanupPidFileIfOwner, writePid } from './daemon-control.js';
59
58
  import { createGuardianActionCopyGenerator, createGuardianFollowUpConversationGenerator } from './guardian-action-generators.js';
@@ -372,7 +371,114 @@ export async function runDaemon(): Promise<void> {
372
371
  try {
373
372
  await runtimeHttp.start();
374
373
  setRelayBroadcast((msg) => server.broadcast(msg));
375
- setPointerCopyGenerator(createPointerCopyGenerator());
374
+ setPointerMessageProcessor(async (conversationId, instruction, requiredFacts) => {
375
+ const session = await server.getSessionForMessages(conversationId);
376
+
377
+ // Constrain pointer generation to a tool-disabled path so call-
378
+ // status events cannot trigger unintended side-effect tools.
379
+ // Incrementing toolsDisabledDepth causes the resolveTools callback
380
+ // to return an empty tool list, preventing the LLM from seeing or
381
+ // invoking any tools during the pointer agent loop.
382
+ //
383
+ // A depth counter (rather than a boolean) ensures that overlapping
384
+ // pointer requests on the same session don't clear each other's
385
+ // constraint — each caller increments on entry and decrements in
386
+ // its own finally block.
387
+ session.toolsDisabledDepth++;
388
+ try {
389
+ const messageId = await session.persistUserMessage(
390
+ instruction,
391
+ [],
392
+ undefined,
393
+ { pointerInstruction: true },
394
+ '[Call status event]',
395
+ );
396
+
397
+ // Helper: roll back persisted messages on failure, then reload
398
+ // in-memory history from the (now cleaned) DB. Reloading avoids
399
+ // stale-index issues when context compaction reassigns the
400
+ // messages array during runAgentLoop.
401
+ const rollback = async (extraMessageIds?: string[]) => {
402
+ try { conversationStore.deleteMessageById(messageId); } catch { /* best effort */ }
403
+ for (const id of extraMessageIds ?? []) {
404
+ try { conversationStore.deleteMessageById(id); } catch { /* best effort */ }
405
+ }
406
+ try { await session.loadFromDb(); } catch { /* best effort */ }
407
+ };
408
+
409
+ // Snapshot message IDs before the agent loop so we can diff
410
+ // afterwards to find exactly which messages this run created,
411
+ // avoiding positional heuristics that break under concurrency.
412
+ //
413
+ // Caveat: the diff captures *all* new messages in the
414
+ // conversation during the loop window, not just those from
415
+ // this specific agent loop. If a concurrent pointer event
416
+ // falls back to a deterministic addMessage() while our loop
417
+ // is in flight, that message lands in our diff. The race
418
+ // requires two pointer events for the same conversation
419
+ // within the agent loop window *and* this run must fail or
420
+ // fail fact-check — narrow enough to accept. A future
421
+ // improvement could tag messages with a per-run correlation
422
+ // ID so rollback only targets its own output.
423
+ const preRunMessageIds = new Set(
424
+ conversationStore.getMessages(conversationId).map((m) => m.id),
425
+ );
426
+
427
+ let agentLoopError: string | undefined;
428
+ let generatedText = '';
429
+ await session.runAgentLoop(instruction, messageId, (msg) => {
430
+ if ('type' in msg && msg.type === 'assistant_text_delta' && 'text' in msg) {
431
+ generatedText += (msg as { text: string }).text;
432
+ }
433
+ if ('type' in msg && (msg.type === 'error' || msg.type === 'session_error')) {
434
+ agentLoopError = 'message' in msg
435
+ ? (msg as { message: string }).message
436
+ : 'userMessage' in msg
437
+ ? (msg as { userMessage: string }).userMessage
438
+ : 'Agent loop failed';
439
+ }
440
+ });
441
+
442
+ // Identify messages created during this run by diffing against
443
+ // the pre-run snapshot. This captures all messages added to the
444
+ // conversation during the loop window, which may include messages
445
+ // from concurrent pointer events (see over-capture caveat above).
446
+ const postRunMessages = conversationStore.getMessages(conversationId);
447
+ const createdMessageIds = postRunMessages
448
+ .filter((m) => !preRunMessageIds.has(m.id) && m.id !== messageId)
449
+ .map((m) => m.id);
450
+
451
+ if (agentLoopError) {
452
+ await rollback(createdMessageIds);
453
+ throw new Error(agentLoopError);
454
+ }
455
+
456
+ // Post-generation fact check: verify the assistant's response
457
+ // includes all required factual details (phone number, duration,
458
+ // outcome keyword, etc.). If the model omitted or rewrote them,
459
+ // remove both the instruction and generated messages and throw so
460
+ // the deterministic fallback fires.
461
+ //
462
+ // Validation uses text accumulated from assistant_text_delta
463
+ // events during the agent loop rather than a DB lookup, avoiding
464
+ // any positional ambiguity when concurrent pointer events
465
+ // interleave messages in the conversation.
466
+ if (requiredFacts && requiredFacts.length > 0) {
467
+ const missingFacts = requiredFacts.filter((fact) => !generatedText.includes(fact));
468
+ if (missingFacts.length > 0) {
469
+ log.warn(
470
+ { conversationId, missingFacts },
471
+ 'Generated pointer text failed fact validation — falling back to deterministic',
472
+ );
473
+ await rollback(createdMessageIds);
474
+ throw new Error('Generated pointer text failed fact validation');
475
+ }
476
+ }
477
+ } finally {
478
+ // Restore tool availability so subsequent turns aren't affected.
479
+ session.toolsDisabledDepth--;
480
+ }
481
+ });
376
482
  runtimeHttp.setPairingBroadcast((msg) => server.broadcast(msg as ServerMessage));
377
483
  initPairingHandlers(runtimeHttp.getPairingStore(), bearerToken);
378
484
  initSlashPairingContext(runtimeHttp.getPairingStore());
@@ -133,33 +133,41 @@ function makePendingInteractionRegistrar(
133
133
 
134
134
  // Create a canonical guardian request so IPC/HTTP handlers can find it
135
135
  // via applyCanonicalGuardianDecision.
136
- const guardianContext = session.guardianContext;
137
- const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
138
- const canonicalRequest = createCanonicalGuardianRequest({
139
- id: msg.requestId,
140
- kind: 'tool_approval',
141
- sourceType: resolveCanonicalRequestSourceType(sourceChannel),
142
- sourceChannel,
143
- conversationId,
144
- requesterExternalUserId: guardianContext?.requesterExternalUserId,
145
- requesterChatId: guardianContext?.requesterChatId,
146
- guardianExternalUserId: guardianContext?.guardianExternalUserId,
147
- toolName: msg.toolName,
148
- status: 'pending',
149
- requestCode: generateCanonicalRequestCode(),
150
- expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
151
- });
152
-
153
- // For trusted-contact sessions, bridge to guardian.question so the
154
- // guardian gets notified and can approve via callback/request-code.
155
- if (guardianContext) {
156
- bridgeConfirmationRequestToGuardian({
157
- canonicalRequest,
158
- guardianContext,
136
+ try {
137
+ const guardianContext = session.guardianContext;
138
+ const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
139
+ const canonicalRequest = createCanonicalGuardianRequest({
140
+ id: msg.requestId,
141
+ kind: 'tool_approval',
142
+ sourceType: resolveCanonicalRequestSourceType(sourceChannel),
143
+ sourceChannel,
159
144
  conversationId,
145
+ requesterExternalUserId: guardianContext?.requesterExternalUserId,
146
+ requesterChatId: guardianContext?.requesterChatId,
147
+ guardianExternalUserId: guardianContext?.guardianExternalUserId,
148
+ guardianPrincipalId: guardianContext?.guardianPrincipalId ?? undefined,
160
149
  toolName: msg.toolName,
161
- assistantId: session.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
150
+ status: 'pending',
151
+ requestCode: generateCanonicalRequestCode(),
152
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
162
153
  });
154
+
155
+ // For trusted-contact sessions, bridge to guardian.question so the
156
+ // guardian gets notified and can approve via callback/request-code.
157
+ if (guardianContext) {
158
+ bridgeConfirmationRequestToGuardian({
159
+ canonicalRequest,
160
+ guardianContext,
161
+ conversationId,
162
+ toolName: msg.toolName,
163
+ assistantId: session.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
164
+ });
165
+ }
166
+ } catch (err) {
167
+ log.debug(
168
+ { err, requestId: msg.requestId, conversationId },
169
+ 'Failed to create canonical request from pending interaction registrar',
170
+ );
163
171
  }
164
172
  } else if (msg.type === 'secret_request') {
165
173
  pendingInteractions.register(msg.requestId, {
@@ -111,6 +111,7 @@ export interface AgentLoopSessionContext {
111
111
 
112
112
  readonly coreToolNames: Set<string>;
113
113
  allowedToolNames?: Set<string>;
114
+ toolsDisabledDepth: number;
114
115
  preactivatedSkillIds?: string[];
115
116
  readonly skillProjectionState: Map<string, string>;
116
117
  readonly skillProjectionCache: SkillProjectionCache;
@@ -405,9 +406,9 @@ export async function runAgentLoopImpl(
405
406
  const actorTrust = resolveActorTrust({
406
407
  assistantId: ctx.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
407
408
  sourceChannel: gc.sourceChannel,
408
- externalChatId: gc.requesterChatId,
409
- senderExternalUserId: gc.requesterExternalUserId,
410
- senderDisplayName: gc.requesterSenderDisplayName,
409
+ conversationExternalId: gc.requesterChatId,
410
+ actorExternalId: gc.requesterExternalUserId,
411
+ actorDisplayName: gc.requesterSenderDisplayName,
411
412
  });
412
413
  resolvedInboundActorContext = inboundActorContextFromTrust(actorTrust);
413
414
  } else {
@@ -29,19 +29,11 @@ type GuardianTrustClass = GuardianRuntimeContext['trustClass'];
29
29
  function parseProvenanceTrustClass(metadata: string | null): GuardianTrustClass | undefined {
30
30
  if (!metadata) return undefined;
31
31
  try {
32
- const parsed = JSON.parse(metadata) as {
33
- provenanceTrustClass?: unknown;
34
- provenanceActorRole?: unknown;
35
- };
32
+ const parsed = JSON.parse(metadata) as { provenanceTrustClass?: unknown };
36
33
  const trustClass = parsed?.provenanceTrustClass;
37
34
  if (trustClass === 'guardian' || trustClass === 'trusted_contact' || trustClass === 'unknown') {
38
35
  return trustClass;
39
36
  }
40
- // Legacy fallback for rows persisted before provenanceTrustClass existed.
41
- const legacyRole = parsed?.provenanceActorRole;
42
- if (legacyRole === 'guardian') return 'guardian';
43
- if (legacyRole === 'non-guardian') return 'trusted_contact';
44
- if (legacyRole === 'unverified_channel') return 'unknown';
45
37
  } catch {
46
38
  // Ignore malformed metadata and treat as unknown provenance.
47
39
  }
@@ -383,9 +383,9 @@ export async function processMessage(
383
383
  messageText: trimmedContent,
384
384
  channel: 'vellum',
385
385
  actor: {
386
- externalUserId: undefined,
386
+ externalUserId: session.guardianContext?.guardianExternalUserId,
387
387
  channel: 'vellum',
388
- isTrusted: true,
388
+ guardianPrincipalId: session.guardianContext?.guardianPrincipalId ?? undefined,
389
389
  },
390
390
  conversationId: session.conversationId,
391
391
  pendingRequestIds: canonicalPendingRequestIdsForConversation,
@@ -38,6 +38,8 @@ export interface GuardianRuntimeContext {
38
38
  trustClass: 'guardian' | 'trusted_contact' | 'unknown';
39
39
  guardianChatId?: string;
40
40
  guardianExternalUserId?: string;
41
+ /** Canonical principal ID for the guardian binding. Nullable for backward compatibility — M5 will make this required. */
42
+ guardianPrincipalId?: string | null;
41
43
  requesterIdentifier?: string;
42
44
  requesterDisplayName?: string;
43
45
  requesterSenderDisplayName?: string;
@@ -307,6 +307,8 @@ export interface SkillProjectionContext {
307
307
  readonly skillProjectionCache: SkillProjectionCache;
308
308
  readonly coreToolNames: Set<string>;
309
309
  allowedToolNames?: Set<string>;
310
+ /** When > 0, the resolveTools callback returns no tools at all. */
311
+ toolsDisabledDepth: number;
310
312
  }
311
313
 
312
314
  /**
@@ -322,6 +324,14 @@ export function createResolveToolsCallback(
322
324
  if (toolDefs.length === 0) return undefined;
323
325
 
324
326
  return (history: Message[]) => {
327
+ // When tools are explicitly disabled (e.g. during pointer generation),
328
+ // return an empty tool list so the LLM never sees tool definitions and
329
+ // keep the allowlist empty so no tool execution can slip through.
330
+ if (ctx.toolsDisabledDepth > 0) {
331
+ ctx.allowedToolNames = new Set<string>();
332
+ return [];
333
+ }
334
+
325
335
  const effectivePreactivated = [
326
336
  ...DEFAULT_PREACTIVATED_SKILL_IDS,
327
337
  ...(ctx.preactivatedSkillIds ?? []),
@@ -121,6 +121,7 @@ export class Session {
121
121
  /** @internal */ workingDir: string;
122
122
  /** @internal */ sandboxOverride?: boolean;
123
123
  /** @internal */ allowedToolNames?: Set<string>;
124
+ /** @internal */ toolsDisabledDepth = 0;
124
125
  /** @internal */ preactivatedSkillIds?: string[];
125
126
  /** @internal */ coreToolNames: Set<string>;
126
127
  /** @internal */ readonly skillProjectionState = new Map<string, string>();
@@ -10,6 +10,7 @@
10
10
  import { and, desc, eq } from 'drizzle-orm';
11
11
  import { v4 as uuid } from 'uuid';
12
12
 
13
+ import { IntegrityError } from '../util/errors.js';
13
14
  import { getDb, rawChanges } from './db.js';
14
15
  import {
15
16
  canonicalGuardianDeliveries,
@@ -31,6 +32,7 @@ export interface CanonicalGuardianRequest {
31
32
  requesterExternalUserId: string | null;
32
33
  requesterChatId: string | null;
33
34
  guardianExternalUserId: string | null;
35
+ guardianPrincipalId: string | null;
34
36
  callSessionId: string | null;
35
37
  pendingQuestionId: string | null;
36
38
  questionText: string | null;
@@ -40,6 +42,7 @@ export interface CanonicalGuardianRequest {
40
42
  status: CanonicalRequestStatus;
41
43
  answerText: string | null;
42
44
  decidedByExternalUserId: string | null;
45
+ decidedByPrincipalId: string | null;
43
46
  followupState: string | null;
44
47
  expiresAt: string | null;
45
48
  createdAt: string;
@@ -117,6 +120,7 @@ function rowToRequest(row: typeof canonicalGuardianRequests.$inferSelect): Canon
117
120
  requesterExternalUserId: row.requesterExternalUserId,
118
121
  requesterChatId: row.requesterChatId,
119
122
  guardianExternalUserId: row.guardianExternalUserId,
123
+ guardianPrincipalId: row.guardianPrincipalId,
120
124
  callSessionId: row.callSessionId,
121
125
  pendingQuestionId: row.pendingQuestionId,
122
126
  questionText: row.questionText,
@@ -126,6 +130,7 @@ function rowToRequest(row: typeof canonicalGuardianRequests.$inferSelect): Canon
126
130
  status: row.status as CanonicalRequestStatus,
127
131
  answerText: row.answerText,
128
132
  decidedByExternalUserId: row.decidedByExternalUserId,
133
+ decidedByPrincipalId: row.decidedByPrincipalId,
129
134
  followupState: row.followupState,
130
135
  expiresAt: row.expiresAt,
131
136
  createdAt: row.createdAt,
@@ -160,6 +165,7 @@ export interface CreateCanonicalGuardianRequestParams {
160
165
  requesterExternalUserId?: string;
161
166
  requesterChatId?: string;
162
167
  guardianExternalUserId?: string;
168
+ guardianPrincipalId?: string;
163
169
  callSessionId?: string;
164
170
  pendingQuestionId?: string;
165
171
  questionText?: string;
@@ -169,11 +175,35 @@ export interface CreateCanonicalGuardianRequestParams {
169
175
  status?: CanonicalRequestStatus;
170
176
  answerText?: string;
171
177
  decidedByExternalUserId?: string;
178
+ decidedByPrincipalId?: string;
172
179
  followupState?: string;
173
180
  expiresAt?: string;
174
181
  }
175
182
 
183
+ /**
184
+ * Request kinds that require a guardian decision (approve/deny). These kinds
185
+ * MUST have a `guardianPrincipalId` bound at creation time so the decision
186
+ * can be attributed to a specific principal. Informational kinds (e.g. status
187
+ * updates) are exempt from this requirement.
188
+ */
189
+ const DECISIONABLE_KINDS = new Set([
190
+ 'tool_approval',
191
+ 'tool_grant_request',
192
+ 'pending_question',
193
+ 'access_request',
194
+ ]);
195
+
176
196
  export function createCanonicalGuardianRequest(params: CreateCanonicalGuardianRequestParams): CanonicalGuardianRequest {
197
+ // Guard: decisionable request kinds must have a principal bound at creation
198
+ // time. This ensures every request that will eventually require a guardian
199
+ // decision is attributable to a specific identity. Informational kinds are
200
+ // exempt — they don't participate in the approval flow.
201
+ if (DECISIONABLE_KINDS.has(params.kind) && !params.guardianPrincipalId) {
202
+ throw new IntegrityError(
203
+ `Cannot create decisionable canonical request of kind '${params.kind}' without guardianPrincipalId`,
204
+ );
205
+ }
206
+
177
207
  const db = getDb();
178
208
  const now = new Date().toISOString();
179
209
  const id = params.id ?? uuid();
@@ -187,6 +217,7 @@ export function createCanonicalGuardianRequest(params: CreateCanonicalGuardianRe
187
217
  requesterExternalUserId: params.requesterExternalUserId ?? null,
188
218
  requesterChatId: params.requesterChatId ?? null,
189
219
  guardianExternalUserId: params.guardianExternalUserId ?? null,
220
+ guardianPrincipalId: params.guardianPrincipalId ?? null,
190
221
  callSessionId: params.callSessionId ?? null,
191
222
  pendingQuestionId: params.pendingQuestionId ?? null,
192
223
  questionText: params.questionText ?? null,
@@ -196,6 +227,7 @@ export function createCanonicalGuardianRequest(params: CreateCanonicalGuardianRe
196
227
  status: params.status ?? ('pending' as const),
197
228
  answerText: params.answerText ?? null,
198
229
  decidedByExternalUserId: params.decidedByExternalUserId ?? null,
230
+ decidedByPrincipalId: params.decidedByPrincipalId ?? null,
199
231
  followupState: params.followupState ?? null,
200
232
  expiresAt: params.expiresAt ?? null,
201
233
  createdAt: now,
@@ -239,6 +271,7 @@ export function getCanonicalGuardianRequestByCode(code: string): CanonicalGuardi
239
271
  export interface ListCanonicalGuardianRequestsFilters {
240
272
  status?: CanonicalRequestStatus;
241
273
  guardianExternalUserId?: string;
274
+ guardianPrincipalId?: string;
242
275
  requesterExternalUserId?: string;
243
276
  conversationId?: string;
244
277
  sourceType?: string;
@@ -257,6 +290,9 @@ export function listCanonicalGuardianRequests(filters?: ListCanonicalGuardianReq
257
290
  if (filters?.guardianExternalUserId) {
258
291
  conditions.push(eq(canonicalGuardianRequests.guardianExternalUserId, filters.guardianExternalUserId));
259
292
  }
293
+ if (filters?.guardianPrincipalId) {
294
+ conditions.push(eq(canonicalGuardianRequests.guardianPrincipalId, filters.guardianPrincipalId));
295
+ }
260
296
  if (filters?.conversationId) {
261
297
  conditions.push(eq(canonicalGuardianRequests.conversationId, filters.conversationId));
262
298
  }
@@ -292,6 +328,7 @@ export interface UpdateCanonicalGuardianRequestParams {
292
328
  status?: CanonicalRequestStatus;
293
329
  answerText?: string;
294
330
  decidedByExternalUserId?: string;
331
+ decidedByPrincipalId?: string;
295
332
  followupState?: string | null;
296
333
  expiresAt?: string;
297
334
  }
@@ -307,6 +344,7 @@ export function updateCanonicalGuardianRequest(
307
344
  if (updates.status !== undefined) setValues.status = updates.status;
308
345
  if (updates.answerText !== undefined) setValues.answerText = updates.answerText;
309
346
  if (updates.decidedByExternalUserId !== undefined) setValues.decidedByExternalUserId = updates.decidedByExternalUserId;
347
+ if (updates.decidedByPrincipalId !== undefined) setValues.decidedByPrincipalId = updates.decidedByPrincipalId;
310
348
  if (updates.followupState !== undefined) setValues.followupState = updates.followupState;
311
349
  if (updates.expiresAt !== undefined) setValues.expiresAt = updates.expiresAt;
312
350
 
@@ -322,6 +360,7 @@ export interface ResolveDecision {
322
360
  status: CanonicalRequestStatus;
323
361
  answerText?: string;
324
362
  decidedByExternalUserId?: string;
363
+ decidedByPrincipalId?: string;
325
364
  }
326
365
 
327
366
  /**
@@ -343,6 +382,7 @@ export function resolveCanonicalGuardianRequest(
343
382
  };
344
383
  if (decision.answerText !== undefined) setValues.answerText = decision.answerText;
345
384
  if (decision.decidedByExternalUserId !== undefined) setValues.decidedByExternalUserId = decision.decidedByExternalUserId;
385
+ if (decision.decidedByPrincipalId !== undefined) setValues.decidedByPrincipalId = decision.decidedByPrincipalId;
346
386
 
347
387
  db.update(canonicalGuardianRequests)
348
388
  .set(setValues)
@@ -689,3 +689,29 @@ export function getConversationOriginInterface(conversationId: string): Interfac
689
689
  .get();
690
690
  return parseInterfaceId(row?.originInterface) ?? null;
691
691
  }
692
+
693
+ /**
694
+ * Return the most recent non-null provenanceTrustClass from user messages
695
+ * in the given conversation, or `undefined` if none is found.
696
+ *
697
+ * Used by the pointer message trust resolver to detect conversations
698
+ * whose audience is a guardian or trusted_contact (even if the
699
+ * conversation itself isn't a desktop-origin private thread).
700
+ */
701
+ export function getConversationRecentProvenanceTrustClass(
702
+ conversationId: string,
703
+ ): 'guardian' | 'trusted_contact' | 'unknown' | undefined {
704
+ const row = rawGet<{ metadata: string | null }>(
705
+ `SELECT metadata FROM messages
706
+ WHERE conversation_id = ? AND role = 'user' AND metadata IS NOT NULL
707
+ ORDER BY created_at DESC LIMIT 1`,
708
+ conversationId,
709
+ );
710
+ if (!row?.metadata) return undefined;
711
+ try {
712
+ const parsed = messageMetadataSchema.safeParse(JSON.parse(row.metadata));
713
+ return parsed.success ? parsed.data.provenanceTrustClass : undefined;
714
+ } catch {
715
+ return undefined;
716
+ }
717
+ }
@@ -17,6 +17,7 @@ export {
17
17
  getConversationMemoryScopeId,
18
18
  getConversationOriginChannel,
19
19
  getConversationOriginInterface,
20
+ getConversationRecentProvenanceTrustClass,
20
21
  getConversationThreadType,
21
22
  getMessageById,
22
23
  getMessages,
@@ -19,6 +19,7 @@ import {
19
19
  createSequenceTables,
20
20
  createTasksAndWorkItemsTables,
21
21
  createWatchersAndLogsTables,
22
+ migrateBackfillGuardianPrincipalId,
22
23
  migrateCallSessionMode,
23
24
  migrateCanonicalGuardianDeliveriesDestinationIndex,
24
25
  migrateCanonicalGuardianRequesterChatId,
@@ -30,6 +31,7 @@ import {
30
31
  migrateGuardianActionToolMetadata,
31
32
  migrateGuardianBootstrapToken,
32
33
  migrateGuardianDeliveryConversationIndex,
34
+ migrateGuardianPrincipalIdColumns,
33
35
  migrateGuardianVerificationPurpose,
34
36
  migrateGuardianVerificationSessions,
35
37
  migrateMessagesFtsBackfill,
@@ -173,5 +175,11 @@ export function initializeDb(): void {
173
175
  // 28. Actor token records (hash-only actor token persistence)
174
176
  createActorTokenRecordsTable(database);
175
177
 
178
+ // 29. Guardian principal ID columns on channel_guardian_bindings and canonical_guardian_requests
179
+ migrateGuardianPrincipalIdColumns(database);
180
+
181
+ // 30. Backfill guardianPrincipalId for existing bindings and requests, expire unresolvable pending requests
182
+ migrateBackfillGuardianPrincipalId(database);
183
+
176
184
  validateMigrationState(database);
177
185
  }
@@ -23,6 +23,7 @@ export interface GuardianBinding {
23
23
  channel: string;
24
24
  guardianExternalUserId: string;
25
25
  guardianDeliveryChatId: string;
26
+ guardianPrincipalId: string | null;
26
27
  status: BindingStatus;
27
28
  verifiedAt: number;
28
29
  verifiedVia: string;
@@ -42,6 +43,7 @@ function rowToBinding(row: typeof channelGuardianBindings.$inferSelect): Guardia
42
43
  channel: row.channel,
43
44
  guardianExternalUserId: row.guardianExternalUserId,
44
45
  guardianDeliveryChatId: row.guardianDeliveryChatId,
46
+ guardianPrincipalId: row.guardianPrincipalId,
45
47
  status: row.status as BindingStatus,
46
48
  verifiedAt: row.verifiedAt,
47
49
  verifiedVia: row.verifiedVia,
@@ -60,6 +62,7 @@ export function createBinding(params: {
60
62
  channel: string;
61
63
  guardianExternalUserId: string;
62
64
  guardianDeliveryChatId: string;
65
+ guardianPrincipalId?: string | null;
63
66
  verifiedVia?: string;
64
67
  metadataJson?: string | null;
65
68
  }): GuardianBinding {
@@ -73,6 +76,7 @@ export function createBinding(params: {
73
76
  channel: params.channel,
74
77
  guardianExternalUserId: params.guardianExternalUserId,
75
78
  guardianDeliveryChatId: params.guardianDeliveryChatId,
79
+ guardianPrincipalId: params.guardianPrincipalId ?? null,
76
80
  status: 'active' as const,
77
81
  verifiedAt: now,
78
82
  verifiedVia: params.verifiedVia ?? 'challenge',
@@ -29,16 +29,9 @@ type ProvenanceTrustClass = 'guardian' | 'trusted_contact' | 'unknown';
29
29
  function parseProvenanceTrustClass(rawMetadata: string | null): ProvenanceTrustClass | undefined {
30
30
  if (!rawMetadata) return undefined;
31
31
  try {
32
- const parsedJson: unknown = JSON.parse(rawMetadata);
33
- const parsed = messageMetadataSchema.safeParse(parsedJson);
32
+ const parsed = messageMetadataSchema.safeParse(JSON.parse(rawMetadata));
34
33
  if (!parsed.success) return undefined;
35
- if (parsed.data.provenanceTrustClass) return parsed.data.provenanceTrustClass;
36
- // Legacy fallback for rows written before provenanceTrustClass existed.
37
- const legacyRole = (parsedJson as { provenanceActorRole?: unknown }).provenanceActorRole;
38
- if (legacyRole === 'guardian') return 'guardian';
39
- if (legacyRole === 'non-guardian') return 'trusted_contact';
40
- if (legacyRole === 'unverified_channel') return 'unknown';
41
- return undefined;
34
+ return parsed.data.provenanceTrustClass;
42
35
  } catch {
43
36
  return undefined;
44
37
  }
@@ -0,0 +1,19 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add guardian_principal_id columns to channel_guardian_bindings and
5
+ * canonical_guardian_requests, plus decided_by_principal_id to
6
+ * canonical_guardian_requests.
7
+ *
8
+ * These nullable TEXT columns support the canonical identity binding
9
+ * cutover — linking guardian bindings and approval requests to a
10
+ * stable principal identity rather than relying solely on
11
+ * channel-specific external user IDs.
12
+ *
13
+ * Uses ALTER TABLE ADD COLUMN with try/catch for idempotency.
14
+ */
15
+ export function migrateGuardianPrincipalIdColumns(database: DrizzleDb): void {
16
+ try { database.run(/*sql*/ `ALTER TABLE channel_guardian_bindings ADD COLUMN guardian_principal_id TEXT`); } catch { /* already exists */ }
17
+ try { database.run(/*sql*/ `ALTER TABLE canonical_guardian_requests ADD COLUMN guardian_principal_id TEXT`); } catch { /* already exists */ }
18
+ try { database.run(/*sql*/ `ALTER TABLE canonical_guardian_requests ADD COLUMN decided_by_principal_id TEXT`); } catch { /* already exists */ }
19
+ }