@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.
- package/ARCHITECTURE.md +4 -4
- package/README.md +6 -6
- package/bun.lock +6 -2
- package/docs/architecture/memory.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/actor-token-service.test.ts +5 -2
- package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
- package/src/__tests__/call-controller.test.ts +78 -0
- package/src/__tests__/call-domain.test.ts +148 -10
- package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
- package/src/__tests__/call-pointer-messages.test.ts +105 -43
- package/src/__tests__/canonical-guardian-store.test.ts +44 -10
- package/src/__tests__/channel-approval-routes.test.ts +67 -65
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
- package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
- package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
- package/src/__tests__/guardian-grant-minting.test.ts +24 -24
- package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
- package/src/__tests__/guardian-routing-state.test.ts +4 -4
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
- package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
- package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
- package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +50 -47
- package/src/__tests__/relay-server.test.ts +71 -0
- package/src/__tests__/send-endpoint-busy.test.ts +6 -0
- package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
- package/src/__tests__/system-prompt.test.ts +1 -0
- package/src/__tests__/tool-approval-handler.test.ts +1 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +9 -2
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +8 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +22 -22
- package/src/__tests__/trusted-contact-multichannel.test.ts +4 -4
- package/src/__tests__/trusted-contact-verification.test.ts +10 -10
- package/src/approvals/guardian-decision-primitive.ts +29 -25
- package/src/approvals/guardian-request-resolvers.ts +9 -5
- package/src/calls/call-pointer-message-composer.ts +27 -85
- package/src/calls/call-pointer-messages.ts +54 -21
- package/src/calls/guardian-dispatch.ts +30 -0
- package/src/calls/relay-server.ts +13 -13
- package/src/config/system-prompt.ts +10 -3
- package/src/config/templates/BOOTSTRAP.md +6 -5
- package/src/config/templates/USER.md +1 -0
- package/src/config/user-reference.ts +44 -0
- package/src/daemon/handlers/guardian-actions.ts +5 -2
- package/src/daemon/handlers/sessions.ts +8 -3
- package/src/daemon/lifecycle.ts +109 -3
- package/src/daemon/server.ts +32 -24
- package/src/daemon/session-agent-loop.ts +4 -3
- package/src/daemon/session-lifecycle.ts +1 -9
- package/src/daemon/session-process.ts +2 -2
- package/src/daemon/session-runtime-assembly.ts +2 -0
- package/src/daemon/session-tool-setup.ts +10 -0
- package/src/daemon/session.ts +1 -0
- package/src/memory/canonical-guardian-store.ts +40 -0
- package/src/memory/conversation-crud.ts +26 -0
- package/src/memory/conversation-store.ts +1 -0
- package/src/memory/db-init.ts +8 -0
- package/src/memory/guardian-bindings.ts +4 -0
- package/src/memory/job-handlers/backfill.ts +2 -9
- package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
- package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema.ts +3 -0
- package/src/notifications/copy-composer.ts +2 -2
- package/src/runtime/access-request-helper.ts +43 -28
- package/src/runtime/actor-trust-resolver.ts +19 -14
- package/src/runtime/channel-guardian-service.ts +6 -0
- package/src/runtime/guardian-context-resolver.ts +6 -2
- package/src/runtime/guardian-reply-router.ts +33 -16
- package/src/runtime/guardian-vellum-migration.ts +29 -5
- package/src/runtime/http-types.ts +0 -13
- package/src/runtime/local-actor-identity.ts +19 -13
- package/src/runtime/middleware/actor-token.ts +2 -2
- package/src/runtime/routes/channel-delivery-routes.ts +5 -5
- package/src/runtime/routes/conversation-routes.ts +45 -35
- package/src/runtime/routes/guardian-action-routes.ts +7 -1
- package/src/runtime/routes/guardian-approval-interception.ts +52 -52
- package/src/runtime/routes/guardian-bootstrap-routes.ts +1 -0
- package/src/runtime/routes/inbound-conversation.ts +7 -7
- package/src/runtime/routes/inbound-message-handler.ts +105 -94
- package/src/runtime/tool-grant-request-helper.ts +1 -0
- package/src/util/logger.ts +10 -0
- package/src/daemon/call-pointer-generators.ts +0 -59
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { config as dotenvConfig } from 'dotenv';
|
|
6
6
|
|
|
7
|
-
import {
|
|
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
|
-
|
|
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());
|
package/src/daemon/server.ts
CHANGED
|
@@ -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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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:
|
|
386
|
+
externalUserId: session.guardianContext?.guardianExternalUserId,
|
|
387
387
|
channel: 'vellum',
|
|
388
|
-
|
|
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 ?? []),
|
package/src/daemon/session.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/memory/db-init.ts
CHANGED
|
@@ -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
|
|
33
|
-
const parsed = messageMetadataSchema.safeParse(parsedJson);
|
|
32
|
+
const parsed = messageMetadataSchema.safeParse(JSON.parse(rawMetadata));
|
|
34
33
|
if (!parsed.success) return undefined;
|
|
35
|
-
|
|
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
|
+
}
|