@vellumai/assistant 0.4.5 → 0.4.7

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 (112) hide show
  1. package/ARCHITECTURE.md +27 -10
  2. package/README.md +6 -6
  3. package/bun.lock +57 -2
  4. package/docs/architecture/memory.md +4 -4
  5. package/docs/trusted-contact-access.md +8 -0
  6. package/package.json +3 -2
  7. package/src/__tests__/actor-token-service.test.ts +9 -6
  8. package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
  9. package/src/__tests__/call-controller.test.ts +115 -0
  10. package/src/__tests__/call-domain.test.ts +148 -10
  11. package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
  12. package/src/__tests__/call-pointer-messages.test.ts +105 -43
  13. package/src/__tests__/canonical-guardian-store.test.ts +44 -10
  14. package/src/__tests__/channel-approval-routes.test.ts +67 -65
  15. package/src/__tests__/channel-delivery-store.test.ts +2 -2
  16. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
  17. package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
  18. package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
  19. package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
  20. package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
  21. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
  22. package/src/__tests__/guardian-dispatch.test.ts +39 -1
  23. package/src/__tests__/guardian-grant-minting.test.ts +24 -24
  24. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
  25. package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
  26. package/src/__tests__/guardian-routing-state.test.ts +10 -32
  27. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
  28. package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
  29. package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
  30. package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
  31. package/src/__tests__/non-member-access-request.test.ts +57 -47
  32. package/src/__tests__/notification-decision-fallback.test.ts +232 -0
  33. package/src/__tests__/notification-decision-strategy.test.ts +304 -8
  34. package/src/__tests__/notification-guardian-path.test.ts +38 -1
  35. package/src/__tests__/relay-server.test.ts +136 -5
  36. package/src/__tests__/send-endpoint-busy.test.ts +35 -1
  37. package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
  39. package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
  40. package/src/__tests__/system-prompt.test.ts +1 -0
  41. package/src/__tests__/tool-approval-handler.test.ts +1 -1
  42. package/src/__tests__/tool-grant-request-escalation.test.ts +10 -2
  43. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +14 -1
  44. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +24 -24
  45. package/src/__tests__/trusted-contact-multichannel.test.ts +5 -5
  46. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  47. package/src/approvals/guardian-decision-primitive.ts +29 -25
  48. package/src/approvals/guardian-request-resolvers.ts +9 -5
  49. package/src/calls/call-controller.ts +15 -0
  50. package/src/calls/call-pointer-message-composer.ts +27 -85
  51. package/src/calls/call-pointer-messages.ts +54 -21
  52. package/src/calls/guardian-dispatch.ts +30 -0
  53. package/src/calls/relay-server.ts +58 -24
  54. package/src/calls/types.ts +1 -0
  55. package/src/config/system-prompt.ts +10 -3
  56. package/src/config/templates/BOOTSTRAP.md +6 -5
  57. package/src/config/templates/USER.md +1 -0
  58. package/src/config/user-reference.ts +44 -0
  59. package/src/daemon/handlers/guardian-actions.ts +5 -2
  60. package/src/daemon/handlers/sessions.ts +8 -3
  61. package/src/daemon/lifecycle.ts +109 -3
  62. package/src/daemon/providers-setup.ts +0 -8
  63. package/src/daemon/server.ts +32 -24
  64. package/src/daemon/session-agent-loop.ts +4 -3
  65. package/src/daemon/session-lifecycle.ts +1 -9
  66. package/src/daemon/session-process.ts +2 -2
  67. package/src/daemon/session-runtime-assembly.ts +2 -0
  68. package/src/daemon/session-slash.ts +35 -2
  69. package/src/daemon/session-tool-setup.ts +10 -0
  70. package/src/daemon/session.ts +1 -0
  71. package/src/memory/canonical-guardian-store.ts +40 -0
  72. package/src/memory/conversation-crud.ts +26 -0
  73. package/src/memory/conversation-store.ts +1 -0
  74. package/src/memory/db-init.ts +12 -0
  75. package/src/memory/guardian-bindings.ts +4 -0
  76. package/src/memory/job-handlers/backfill.ts +2 -9
  77. package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
  78. package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
  79. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
  80. package/src/memory/migrations/index.ts +3 -0
  81. package/src/memory/migrations/registry.ts +5 -0
  82. package/src/memory/schema.ts +22 -0
  83. package/src/notifications/README.md +8 -1
  84. package/src/notifications/copy-composer.ts +160 -30
  85. package/src/notifications/decision-engine.ts +98 -1
  86. package/src/runtime/access-request-helper.ts +43 -28
  87. package/src/runtime/actor-refresh-token-service.ts +309 -0
  88. package/src/runtime/actor-refresh-token-store.ts +157 -0
  89. package/src/runtime/actor-token-service.ts +3 -3
  90. package/src/runtime/actor-trust-resolver.ts +19 -14
  91. package/src/runtime/channel-guardian-service.ts +6 -0
  92. package/src/runtime/gateway-client.ts +239 -0
  93. package/src/runtime/guardian-context-resolver.ts +6 -2
  94. package/src/runtime/guardian-reply-router.ts +33 -16
  95. package/src/runtime/guardian-vellum-migration.ts +29 -5
  96. package/src/runtime/http-server.ts +2 -0
  97. package/src/runtime/http-types.ts +0 -13
  98. package/src/runtime/local-actor-identity.ts +19 -13
  99. package/src/runtime/middleware/actor-token.ts +2 -2
  100. package/src/runtime/routes/channel-delivery-routes.ts +5 -5
  101. package/src/runtime/routes/conversation-routes.ts +45 -35
  102. package/src/runtime/routes/guardian-action-routes.ts +7 -1
  103. package/src/runtime/routes/guardian-approval-interception.ts +52 -52
  104. package/src/runtime/routes/guardian-bootstrap-routes.ts +11 -24
  105. package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
  106. package/src/runtime/routes/inbound-conversation.ts +7 -7
  107. package/src/runtime/routes/inbound-message-handler.ts +105 -94
  108. package/src/runtime/routes/pairing-routes.ts +60 -50
  109. package/src/runtime/tool-grant-request-helper.ts +1 -0
  110. package/src/types/qrcode.d.ts +10 -0
  111. package/src/util/logger.ts +10 -0
  112. package/src/daemon/call-pointer-generators.ts +0 -59
@@ -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,51 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Create the actor_refresh_token_records table for hash-only refresh token persistence.
5
+ *
6
+ * Stores the SHA-256 hash of each refresh token with family tracking,
7
+ * device binding, and dual expiry (absolute + inactivity).
8
+ * The raw token plaintext is never stored.
9
+ */
10
+ export function createActorRefreshTokenRecordsTable(database: DrizzleDb): void {
11
+ database.run(/*sql*/ `
12
+ CREATE TABLE IF NOT EXISTS actor_refresh_token_records (
13
+ id TEXT PRIMARY KEY,
14
+ token_hash TEXT NOT NULL,
15
+ family_id TEXT NOT NULL,
16
+ assistant_id TEXT NOT NULL,
17
+ guardian_principal_id TEXT NOT NULL,
18
+ hashed_device_id TEXT NOT NULL,
19
+ platform TEXT NOT NULL,
20
+ status TEXT NOT NULL DEFAULT 'active',
21
+ issued_at INTEGER NOT NULL,
22
+ absolute_expires_at INTEGER NOT NULL,
23
+ inactivity_expires_at INTEGER NOT NULL,
24
+ last_used_at INTEGER,
25
+ created_at INTEGER NOT NULL,
26
+ updated_at INTEGER NOT NULL
27
+ )
28
+ `);
29
+
30
+ // Token hash lookup (any status — needed for replay detection)
31
+ database.run(
32
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash
33
+ ON actor_refresh_token_records(token_hash)`,
34
+ );
35
+
36
+ // Unique active refresh token per device binding.
37
+ // DROP first so that databases that already created the older non-unique
38
+ // index with the same name get upgraded to UNIQUE.
39
+ database.run(/*sql*/ `DROP INDEX IF EXISTS idx_refresh_tokens_active_device`);
40
+ database.run(
41
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
42
+ ON actor_refresh_token_records(assistant_id, guardian_principal_id, hashed_device_id)
43
+ WHERE status = 'active'`,
44
+ );
45
+
46
+ // Family lookup for replay detection (revoke entire family)
47
+ database.run(
48
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family
49
+ ON actor_refresh_token_records(family_id)`,
50
+ );
51
+ }
@@ -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
+ }
@@ -0,0 +1,210 @@
1
+ import { type DrizzleDb, getSqliteFrom } from '../db-connection.js';
2
+ import { withCrashRecovery } from './validate-migration-state.js';
3
+
4
+ /**
5
+ * Backfill guardianPrincipalId for existing channel_guardian_bindings and
6
+ * canonical_guardian_requests rows.
7
+ *
8
+ * Strategy:
9
+ *
10
+ * 1. Derive the assistant's canonical principal from the active 'vellum'
11
+ * binding's guardianExternalUserId. This is the stable identity used for
12
+ * all guardian decisions from the desktop client.
13
+ *
14
+ * 2. Backfill channel_guardian_bindings:
15
+ * a. For the vellum binding: set guardianPrincipalId = guardianExternalUserId
16
+ * (the vellum external user ID IS the canonical principal).
17
+ * b. For non-vellum bindings: set guardianPrincipalId to the vellum
18
+ * binding's principal (unifying all channels onto one canonical
19
+ * principal). Falls back to guardianExternalUserId if no vellum
20
+ * binding exists.
21
+ *
22
+ * 3. Backfill canonical_guardian_requests (pending only):
23
+ * a. If the request has a guardianExternalUserId that maps to an active
24
+ * binding, use that binding's guardianPrincipalId (now backfilled).
25
+ * b. For desktop-originated requests (sourceType = 'desktop' or
26
+ * sourceChannel = 'vellum') that lack guardianExternalUserId,
27
+ * use the assistant principal derived in step 1.
28
+ * c. Pending requests that cannot be deterministically bound are expired
29
+ * (including access_request rows, which are now principal-bound).
30
+ *
31
+ * 4. Idempotent: uses checkpoint key + only updates rows with NULL
32
+ * guardianPrincipalId.
33
+ */
34
+ export function migrateBackfillGuardianPrincipalId(database: DrizzleDb): void {
35
+ withCrashRecovery(database, 'migration_backfill_guardian_principal_id_v3', () => {
36
+ const raw = getSqliteFrom(database);
37
+
38
+ // Guard: tables must exist
39
+ const bindingsTableExists = raw.query(
40
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'channel_guardian_bindings'`,
41
+ ).get();
42
+ if (!bindingsTableExists) return;
43
+
44
+ const requestsTableExists = raw.query(
45
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'canonical_guardian_requests'`,
46
+ ).get();
47
+
48
+ // Guard: guardian_principal_id column must exist on bindings table
49
+ const bindingColExists = raw.query(
50
+ `SELECT 1 FROM pragma_table_info('channel_guardian_bindings') WHERE name = 'guardian_principal_id'`,
51
+ ).get();
52
+ if (!bindingColExists) return;
53
+
54
+ try {
55
+ raw.exec('BEGIN');
56
+
57
+ // ── Step 1a: Backfill vellum binding first ─────────────────────
58
+ // The vellum binding's external user ID IS the canonical principal.
59
+ raw.exec(/*sql*/ `
60
+ UPDATE channel_guardian_bindings
61
+ SET guardian_principal_id = guardian_external_user_id,
62
+ updated_at = ${Date.now()}
63
+ WHERE status = 'active'
64
+ AND channel = 'vellum'
65
+ AND guardian_external_user_id IS NOT NULL
66
+ AND guardian_principal_id IS NULL
67
+ `);
68
+
69
+ // ── Step 1b: Derive canonical principal from vellum binding ──
70
+ const vellumRow = raw.query(
71
+ `SELECT guardian_principal_id FROM channel_guardian_bindings
72
+ WHERE assistant_id = 'self' AND channel = 'vellum' AND status = 'active'
73
+ AND guardian_principal_id IS NOT NULL LIMIT 1`,
74
+ ).get() as { guardian_principal_id: string } | null;
75
+
76
+ if (vellumRow) {
77
+ // Unify non-vellum bindings onto the canonical principal
78
+ raw.query(
79
+ `UPDATE channel_guardian_bindings
80
+ SET guardian_principal_id = ?,
81
+ updated_at = ${Date.now()}
82
+ WHERE status = 'active'
83
+ AND channel != 'vellum'
84
+ AND guardian_external_user_id IS NOT NULL
85
+ AND guardian_principal_id IS NULL`,
86
+ ).run(vellumRow.guardian_principal_id);
87
+ } else {
88
+ // No vellum binding — fallback to channel-specific principal
89
+ raw.exec(/*sql*/ `
90
+ UPDATE channel_guardian_bindings
91
+ SET guardian_principal_id = guardian_external_user_id,
92
+ updated_at = ${Date.now()}
93
+ WHERE status = 'active'
94
+ AND guardian_external_user_id IS NOT NULL
95
+ AND guardian_principal_id IS NULL
96
+ `);
97
+ }
98
+
99
+ // ── Step 2: Derive assistant principal from vellum binding ─────
100
+ // The vellum binding's guardianExternalUserId is the canonical
101
+ // assistant principal used for desktop-originated requests.
102
+ const vellumBinding = raw.query(
103
+ `SELECT guardian_external_user_id, guardian_principal_id FROM channel_guardian_bindings WHERE assistant_id = 'self' AND channel = 'vellum' AND status = 'active' LIMIT 1`,
104
+ ).get() as { guardian_external_user_id: string; guardian_principal_id: string | null } | null;
105
+
106
+ // Use the (now-backfilled) principal from the vellum binding
107
+ const assistantPrincipal = vellumBinding?.guardian_principal_id ?? vellumBinding?.guardian_external_user_id ?? null;
108
+
109
+ // ── Step 3: Backfill canonical_guardian_requests ────────────────
110
+ if (requestsTableExists) {
111
+ const requestColExists = raw.query(
112
+ `SELECT 1 FROM pragma_table_info('canonical_guardian_requests') WHERE name = 'guardian_principal_id'`,
113
+ ).get();
114
+
115
+ if (requestColExists) {
116
+ const now = new Date().toISOString();
117
+
118
+ // 3a. Pending requests with a guardianExternalUserId that maps
119
+ // to an active binding — use the binding's principal.
120
+ // Includes all kinds; binding is always useful when available.
121
+ const pendingWithGuardian = raw.query(
122
+ `SELECT r.id, r.guardian_external_user_id, r.kind
123
+ FROM canonical_guardian_requests r
124
+ WHERE r.status = 'pending'
125
+ AND r.guardian_principal_id IS NULL
126
+ AND r.guardian_external_user_id IS NOT NULL`,
127
+ ).all() as Array<{ id: string; guardian_external_user_id: string; kind: string }>;
128
+
129
+ // Build a lookup of guardianExternalUserId -> principalId from
130
+ // active bindings (all already backfilled in step 1).
131
+ const activeBindings = raw.query(
132
+ `SELECT guardian_external_user_id, guardian_principal_id
133
+ FROM channel_guardian_bindings
134
+ WHERE assistant_id = 'self' AND status = 'active' AND guardian_principal_id IS NOT NULL`,
135
+ ).all() as Array<{ guardian_external_user_id: string; guardian_principal_id: string }>;
136
+
137
+ const externalToP = new Map<string, string>();
138
+ for (const b of activeBindings) {
139
+ externalToP.set(b.guardian_external_user_id, b.guardian_principal_id);
140
+ }
141
+
142
+ const updateStmt = raw.prepare(
143
+ `UPDATE canonical_guardian_requests SET guardian_principal_id = ?, updated_at = ? WHERE id = ?`,
144
+ );
145
+ const expireStmt = raw.prepare(
146
+ `UPDATE canonical_guardian_requests SET status = 'expired', updated_at = ? WHERE id = ?`,
147
+ );
148
+
149
+ const unboundRequestIds: string[] = [];
150
+
151
+ for (const req of pendingWithGuardian) {
152
+ const principal = externalToP.get(req.guardian_external_user_id);
153
+ if (principal) {
154
+ updateStmt.run(principal, now, req.id);
155
+ } else {
156
+ // Cannot deterministically map — will expire below.
157
+ unboundRequestIds.push(req.id);
158
+ }
159
+ }
160
+
161
+ // 3b. Desktop-originated pending requests missing guardian info
162
+ // entirely — bind to the assistant principal. Only applies to
163
+ // requests that also lack a guardian external user ID; requests
164
+ // that carry an external ID but failed step 3a mapping should be
165
+ // expired, not reassigned.
166
+ if (assistantPrincipal) {
167
+ raw.query(
168
+ `UPDATE canonical_guardian_requests
169
+ SET guardian_principal_id = ?, updated_at = ?
170
+ WHERE status = 'pending'
171
+ AND guardian_principal_id IS NULL
172
+ AND guardian_external_user_id IS NULL
173
+ AND (source_type = 'desktop' OR source_channel = 'vellum')`,
174
+ ).run(assistantPrincipal, now);
175
+ }
176
+
177
+ // 3c. Expire remaining pending requests that still have no
178
+ // guardian_principal_id. These requests can never be approved
179
+ // in the principal-based system, so they must be expired
180
+ // proactively. This includes access_request rows which are
181
+ // now decisionable and principal-bound.
182
+ const stillUnbound = raw.query(
183
+ `SELECT id FROM canonical_guardian_requests
184
+ WHERE guardian_principal_id IS NULL
185
+ AND status = 'pending'`,
186
+ ).all() as Array<{ id: string }>;
187
+
188
+ for (const req of stillUnbound) {
189
+ expireStmt.run(now, req.id);
190
+ }
191
+
192
+ // Also expire requests identified in 3a that had no binding match.
193
+ for (const id of unboundRequestIds) {
194
+ const check = raw.query(
195
+ `SELECT guardian_principal_id FROM canonical_guardian_requests WHERE id = ? AND status = 'pending'`,
196
+ ).get(id) as { guardian_principal_id: string | null } | null;
197
+ if (check && !check.guardian_principal_id) {
198
+ expireStmt.run(now, id);
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ raw.exec('COMMIT');
205
+ } catch (e) {
206
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
207
+ throw e;
208
+ }
209
+ });
210
+ }
@@ -40,6 +40,7 @@ export { migrateGuardianActionSupersession } from './035-guardian-action-superse
40
40
  export { migrateNormalizePhoneIdentities } from './036-normalize-phone-identities.js';
41
41
  export { migrateVoiceInviteColumns } from './037-voice-invite-columns.js';
42
42
  export { createActorTokenRecordsTable } from './038-actor-token-records.js';
43
+ export { createActorRefreshTokenRecordsTable } from './039-actor-refresh-token-records.js';
43
44
  export { createCoreTables } from './100-core-tables.js';
44
45
  export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
45
46
  export { addCoreColumns } from './102-alter-table-columns.js';
@@ -65,6 +66,8 @@ export { createCanonicalGuardianTables } from './121-canonical-guardian-requests
65
66
  export { migrateCanonicalGuardianRequesterChatId } from './122-canonical-guardian-requester-chat-id.js';
66
67
  export { migrateCanonicalGuardianDeliveriesDestinationIndex } from './123-canonical-guardian-deliveries-destination-index.js';
67
68
  export { migrateVoiceInviteDisplayMetadata } from './124-voice-invite-display-metadata.js';
69
+ export { migrateGuardianPrincipalIdColumns } from './125-guardian-principal-id-columns.js';
70
+ export { migrateBackfillGuardianPrincipalId } from './126-backfill-guardian-principal-id.js';
68
71
  export {
69
72
  MIGRATION_REGISTRY,
70
73
  type MigrationRegistryEntry,
@@ -95,6 +95,11 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
95
95
  version: 14,
96
96
  description: 'Normalize phone-like identity fields to E.164 format across guardian bindings, verification challenges, canonical requests, ingress members, and rate limits',
97
97
  },
98
+ {
99
+ key: 'migration_backfill_guardian_principal_id_v3',
100
+ version: 15,
101
+ description: 'Backfill guardianPrincipalId for existing channel_guardian_bindings and canonical_guardian_requests rows, expire unresolvable pending requests',
102
+ },
98
103
  ];
99
104
 
100
105
  export interface MigrationValidationResult {
@@ -637,6 +637,7 @@ export const channelGuardianBindings = sqliteTable('channel_guardian_bindings',
637
637
  channel: text('channel').notNull(),
638
638
  guardianExternalUserId: text('guardian_external_user_id').notNull(),
639
639
  guardianDeliveryChatId: text('guardian_delivery_chat_id').notNull(),
640
+ guardianPrincipalId: text('guardian_principal_id'),
640
641
  status: text('status').notNull().default('active'),
641
642
  verifiedAt: integer('verified_at').notNull(),
642
643
  verifiedVia: text('verified_via').notNull().default('challenge'),
@@ -887,6 +888,7 @@ export const canonicalGuardianRequests = sqliteTable('canonical_guardian_request
887
888
  requesterExternalUserId: text('requester_external_user_id'),
888
889
  requesterChatId: text('requester_chat_id'),
889
890
  guardianExternalUserId: text('guardian_external_user_id'),
891
+ guardianPrincipalId: text('guardian_principal_id'),
890
892
  callSessionId: text('call_session_id'),
891
893
  pendingQuestionId: text('pending_question_id'),
892
894
  questionText: text('question_text'),
@@ -896,6 +898,7 @@ export const canonicalGuardianRequests = sqliteTable('canonical_guardian_request
896
898
  status: text('status').notNull().default('pending'),
897
899
  answerText: text('answer_text'),
898
900
  decidedByExternalUserId: text('decided_by_external_user_id'),
901
+ decidedByPrincipalId: text('decided_by_principal_id'),
899
902
  followupState: text('followup_state'),
900
903
  expiresAt: text('expires_at'),
901
904
  createdAt: text('created_at').notNull(),
@@ -1159,6 +1162,25 @@ export const actorTokenRecords = sqliteTable('actor_token_records', {
1159
1162
  updatedAt: integer('updated_at').notNull(),
1160
1163
  });
1161
1164
 
1165
+ // ── Actor Refresh Token Records ──────────────────────────────────────
1166
+
1167
+ export const actorRefreshTokenRecords = sqliteTable('actor_refresh_token_records', {
1168
+ id: text('id').primaryKey(),
1169
+ tokenHash: text('token_hash').notNull(),
1170
+ familyId: text('family_id').notNull(),
1171
+ assistantId: text('assistant_id').notNull(),
1172
+ guardianPrincipalId: text('guardian_principal_id').notNull(),
1173
+ hashedDeviceId: text('hashed_device_id').notNull(),
1174
+ platform: text('platform').notNull(),
1175
+ status: text('status').notNull().default('active'),
1176
+ issuedAt: integer('issued_at').notNull(),
1177
+ absoluteExpiresAt: integer('absolute_expires_at').notNull(),
1178
+ inactivityExpiresAt: integer('inactivity_expires_at').notNull(),
1179
+ lastUsedAt: integer('last_used_at'),
1180
+ createdAt: integer('created_at').notNull(),
1181
+ updatedAt: integer('updated_at').notNull(),
1182
+ });
1183
+
1162
1184
  // ── Scoped Approval Grants ──────────────────────────────────────────
1163
1185
 
1164
1186
  export const scopedApprovalGrants = sqliteTable('scoped_approval_grants', {
@@ -51,7 +51,14 @@ When the LLM is unavailable or returns invalid output, a deterministic fallback
51
51
 
52
52
  ### 4. Deterministic Checks
53
53
 
54
- Hard invariants that the LLM cannot override (`deterministic-checks.ts`):
54
+ Hard invariants that the LLM cannot override:
55
+
56
+ **Post-generation enforcement** (`decision-engine.ts`):
57
+
58
+ - **Guardian question request-code enforcement** — `enforceGuardianRequestCode()` ensures request-code instructions (approve/reject or free-text answer) appear in all `guardian.question` notification copy, even when the LLM omits them.
59
+ - **Access-request instruction enforcement** — `enforceAccessRequestInstructions()` validates that `ingress.access_request` copy contains: (1) the request-code approve/reject directive, (2) the exact "open invite flow" phrase. If any required element is missing, the full deterministic contract text is appended. This prevents model-generated copy from dropping security-critical action directives.
60
+
61
+ **Pre-send gate checks** (`deterministic-checks.ts`):
55
62
 
56
63
  - **Schema validity** -- fail-closed if the decision is malformed
57
64
  - **Source-active suppression** -- if the user is already viewing the source context, suppress
@@ -29,6 +29,162 @@ export function nonEmpty(value: string | undefined): string | undefined {
29
29
  return trimmed.length > 0 ? trimmed : undefined;
30
30
  }
31
31
 
32
+ // ── Access-request copy contract ─────────────────────────────────────────────
33
+ //
34
+ // Deterministic helpers for building guardian-facing access-request copy.
35
+ // These are used both by the fallback template and the decision-engine
36
+ // post-generation enforcement to ensure required directives always appear.
37
+
38
+ const IDENTITY_FIELD_MAX_LENGTH = 120;
39
+
40
+ /**
41
+ * Sanitize an untrusted identity field for inclusion in notification copy.
42
+ *
43
+ * - Strips control characters (U+0000–U+001F, U+007F–U+009F) and newlines.
44
+ * - Clamps to IDENTITY_FIELD_MAX_LENGTH characters.
45
+ * - Wraps in quotes to neutralize instruction-like payload text.
46
+ */
47
+ export function sanitizeIdentityField(value: string): string {
48
+ const stripped = value.replace(/[\x00-\x1f\x7f-\x9f\r\n]+/g, ' ').trim();
49
+ const clamped = stripped.length > IDENTITY_FIELD_MAX_LENGTH
50
+ ? stripped.slice(0, IDENTITY_FIELD_MAX_LENGTH) + '…'
51
+ : stripped;
52
+ return clamped;
53
+ }
54
+
55
+ export function buildAccessRequestIdentityLine(payload: Record<string, unknown>): string {
56
+ const requester = sanitizeIdentityField(str(payload.senderIdentifier, 'Someone'));
57
+ const sourceChannel = typeof payload.sourceChannel === 'string' ? payload.sourceChannel : undefined;
58
+ const callerName = nonEmpty(typeof payload.actorDisplayName === 'string' ? payload.actorDisplayName : undefined);
59
+ const actorUsername = nonEmpty(typeof payload.actorUsername === 'string' ? payload.actorUsername : undefined);
60
+ const actorExternalId = nonEmpty(typeof payload.actorExternalId === 'string' ? payload.actorExternalId : undefined);
61
+
62
+ if (sourceChannel === 'voice' && callerName) {
63
+ const safeName = sanitizeIdentityField(callerName);
64
+ const safeId = sanitizeIdentityField(str(payload.actorExternalId, requester));
65
+ return `${safeName} (${safeId}) is calling and requesting access to the assistant.`;
66
+ }
67
+
68
+ // For non-voice, include extra context when available.
69
+ // Sanitize before comparing to avoid deduplication failures when identity
70
+ // fields contain control characters that are stripped from `requester`.
71
+ const sanitizedUsername = actorUsername ? sanitizeIdentityField(actorUsername) : undefined;
72
+ const sanitizedExternalId = actorExternalId ? sanitizeIdentityField(actorExternalId) : undefined;
73
+ const parts = [requester];
74
+ if (sanitizedUsername && sanitizedUsername !== requester) {
75
+ parts.push(`@${sanitizedUsername}`);
76
+ }
77
+ if (sanitizedExternalId && sanitizedExternalId !== requester && sanitizedExternalId !== sanitizedUsername) {
78
+ parts.push(`[${sanitizedExternalId}]`);
79
+ }
80
+ if (sourceChannel) {
81
+ parts.push(`via ${sourceChannel}`);
82
+ }
83
+
84
+ return `${parts.join(' ')} is requesting access to the assistant.`;
85
+ }
86
+
87
+ export function buildAccessRequestDecisionDirective(requestCode: string): string {
88
+ const code = requestCode.toUpperCase();
89
+ return `Reply "${code} approve" to grant access or "${code} reject" to deny.`;
90
+ }
91
+
92
+ export function buildAccessRequestInviteDirective(): string {
93
+ return 'Reply "open invite flow" to start Trusted Contacts invite flow.';
94
+ }
95
+
96
+ export function buildAccessRequestRevokedNote(): string {
97
+ return 'Note: this user was previously revoked.';
98
+ }
99
+
100
+ /**
101
+ * Normalize text before running directive-matching regexes.
102
+ *
103
+ * - Replaces smart/curly apostrophes (\u2018, \u2019, \u201B) with ASCII `'`
104
+ * so contractions like "Don\u2019t" are matched by the `n't` lookbehind.
105
+ * - Collapses runs of whitespace into a single space so "Do not reply"
106
+ * is matched by the single-space negative lookbehind.
107
+ * - Trims leading/trailing whitespace.
108
+ */
109
+ export function normalizeForDirectiveMatching(text: string): string {
110
+ return text
111
+ .replace(/[\u2018\u2019\u201B]/g, "'")
112
+ .replace(/\s+/g, ' ')
113
+ .trim();
114
+ }
115
+
116
+ /**
117
+ * Check whether a text contains the required access-request instruction elements:
118
+ * 1. Approve directive: Reply "CODE approve"
119
+ * 2. Reject directive: Reply "CODE reject"
120
+ * 3. Invite directive: Reply "open invite flow"
121
+ *
122
+ * Each directive is matched independently using negative lookbehind to reject
123
+ * matches preceded by negation words ("not", "n't", "never"). This prevents
124
+ * contradictory copy like `Do not reply "CODE reject"` from satisfying the
125
+ * check even when a positive approve directive exists nearby.
126
+ *
127
+ * The text is normalized before matching to handle smart apostrophes and
128
+ * multiple whitespace characters that would otherwise bypass negation detection.
129
+ */
130
+ export function hasAccessRequestInstructions(
131
+ text: string | undefined,
132
+ requestCode: string,
133
+ ): boolean {
134
+ if (typeof text !== 'string') return false;
135
+ const normalized = normalizeForDirectiveMatching(text);
136
+ const escapedCode = requestCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
137
+ // Each directive must follow "reply" without a preceding negation word.
138
+ // Negative lookbehinds reject "do not reply", "don't reply", "never reply".
139
+ const approveRe = new RegExp(
140
+ `(?<!not\\s)(?<!n't\\s)(?<!never\\s)reply\\b[^.!?\\n]*?"${escapedCode}\\s+approve"`,
141
+ 'i',
142
+ );
143
+ const rejectRe = new RegExp(
144
+ `(?<!not\\s)(?<!n't\\s)(?<!never\\s)reply\\b[^.!?\\n]*?"${escapedCode}\\s+reject"`,
145
+ 'i',
146
+ );
147
+ const inviteRe = /(?<!not\s)(?<!n't\s)(?<!never\s)reply\b[^.!?\n]*?"open invite flow"/i;
148
+
149
+ return approveRe.test(normalized) && rejectRe.test(normalized) && inviteRe.test(normalized);
150
+ }
151
+
152
+ /**
153
+ * Check whether text contains the invite-flow directive ("open invite flow")
154
+ * using the same normalized negative-lookbehind pattern as the full check.
155
+ * This is used for enforcement when requestCode is absent but the invite
156
+ * directive should still be present.
157
+ */
158
+ export function hasInviteFlowDirective(text: string | undefined): boolean {
159
+ if (typeof text !== 'string') return false;
160
+ const normalized = normalizeForDirectiveMatching(text);
161
+ const inviteRe = /(?<!not\s)(?<!n't\s)(?<!never\s)reply\b[^.!?\n]*?"open invite flow"/i;
162
+ return inviteRe.test(normalized);
163
+ }
164
+
165
+ /**
166
+ * Build the deterministic access-request contract text from payload fields.
167
+ * This is the canonical baseline that enforcement can append when generated
168
+ * copy is missing required elements.
169
+ */
170
+ export function buildAccessRequestContractText(payload: Record<string, unknown>): string {
171
+ const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
172
+ const previousMemberStatus = typeof payload.previousMemberStatus === 'string'
173
+ ? payload.previousMemberStatus
174
+ : undefined;
175
+
176
+ const lines: string[] = [];
177
+ lines.push(buildAccessRequestIdentityLine(payload));
178
+ if (previousMemberStatus === 'revoked') {
179
+ lines.push(buildAccessRequestRevokedNote());
180
+ }
181
+ if (requestCode) {
182
+ lines.push(buildAccessRequestDecisionDirective(requestCode));
183
+ }
184
+ lines.push(buildAccessRequestInviteDirective());
185
+ return lines.join('\n');
186
+ }
187
+
32
188
  // Templates keyed by dot-separated sourceEventName strings matching producers.
33
189
  const TEMPLATES: Record<string, CopyTemplate> = {
34
190
  'reminder.fired': (payload) => ({
@@ -60,36 +216,10 @@ const TEMPLATES: Record<string, CopyTemplate> = {
60
216
  };
61
217
  },
62
218
 
63
- 'ingress.access_request': (payload) => {
64
- const requester = str(payload.senderIdentifier, 'Someone');
65
- const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
66
- const sourceChannel = typeof payload.sourceChannel === 'string' ? payload.sourceChannel : undefined;
67
- const callerName = nonEmpty(typeof payload.senderName === 'string' ? payload.senderName : undefined);
68
- const previousMemberStatus = typeof payload.previousMemberStatus === 'string'
69
- ? payload.previousMemberStatus
70
- : undefined;
71
- const lines: string[] = [];
72
-
73
- // Voice-originated access requests include caller name context
74
- if (sourceChannel === 'voice' && callerName) {
75
- lines.push(`${callerName} (${str(payload.senderExternalUserId, requester)}) is calling and requesting access to the assistant.`);
76
- } else {
77
- lines.push(`${requester} is requesting access to the assistant.`);
78
- }
79
- if (previousMemberStatus === 'revoked') {
80
- lines.push('Note: this user was previously revoked.');
81
- }
82
-
83
- if (requestCode) {
84
- const code = requestCode.toUpperCase();
85
- lines.push(`Reply "${code} approve" to grant access or "${code} reject" to deny.`);
86
- }
87
- lines.push('Reply "open invite flow" to start Trusted Contacts invite flow.');
88
- return {
89
- title: 'Access Request',
90
- body: lines.join('\n'),
91
- };
92
- },
219
+ 'ingress.access_request': (payload) => ({
220
+ title: 'Access Request',
221
+ body: buildAccessRequestContractText(payload),
222
+ }),
93
223
 
94
224
  'ingress.access_request.callback_handoff': (payload) => {
95
225
  const callerName = nonEmpty(typeof payload.callerName === 'string' ? payload.callerName : undefined);