@agent-native/core 0.98.6 → 0.98.8

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 (88) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/external-agents.mdx +22 -12
  4. package/corpus/core/docs/content/locales/ar-SA/external-agents.mdx +8 -2
  5. package/corpus/core/docs/content/locales/de-DE/external-agents.mdx +8 -2
  6. package/corpus/core/docs/content/locales/es-ES/external-agents.mdx +8 -2
  7. package/corpus/core/docs/content/locales/fr-FR/external-agents.mdx +8 -2
  8. package/corpus/core/docs/content/locales/hi-IN/external-agents.mdx +8 -2
  9. package/corpus/core/docs/content/locales/ja-JP/external-agents.mdx +8 -2
  10. package/corpus/core/docs/content/locales/ko-KR/external-agents.mdx +8 -2
  11. package/corpus/core/docs/content/locales/pt-BR/external-agents.mdx +8 -2
  12. package/corpus/core/docs/content/locales/zh-CN/external-agents.mdx +8 -2
  13. package/corpus/core/docs/content/locales/zh-TW/external-agents.mdx +8 -2
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/a2a/handlers.ts +6 -5
  16. package/corpus/core/src/a2a/task-store.ts +63 -0
  17. package/corpus/core/src/client/session-replay.ts +90 -6
  18. package/corpus/core/src/integrations/adapters/slack.ts +110 -19
  19. package/corpus/core/src/integrations/identity.ts +103 -39
  20. package/corpus/core/src/integrations/pending-tasks-retry-job.ts +2 -1
  21. package/corpus/core/src/integrations/pending-tasks-store.ts +1 -1
  22. package/corpus/core/src/integrations/plugin.ts +215 -8
  23. package/corpus/core/src/integrations/types.ts +37 -0
  24. package/corpus/core/src/integrations/webhook-handler.ts +1 -0
  25. package/corpus/core/src/mcp/builtin-tools.ts +5 -2
  26. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +4 -1
  27. package/corpus/templates/analytics/AGENTS.md +9 -9
  28. package/corpus/templates/analytics/changelog/2026-07-12-analytics-uses-the-full-in-app-agent-for-multi-step-incident.md +6 -0
  29. package/corpus/templates/analytics/server/handlers/session-replay.ts +15 -9
  30. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +23 -5
  31. package/corpus/templates/analytics/server/lib/session-replay.ts +26 -5
  32. package/corpus/templates/analytics/server/plugins/agent-chat.ts +4 -1
  33. package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +8 -5
  34. package/corpus/templates/analytics/server/routes/api/session-replay/agent-events.json.get.ts +8 -3
  35. package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +12 -2
  36. package/dist/a2a/handlers.d.ts.map +1 -1
  37. package/dist/a2a/handlers.js +6 -6
  38. package/dist/a2a/handlers.js.map +1 -1
  39. package/dist/a2a/task-store.d.ts +12 -0
  40. package/dist/a2a/task-store.d.ts.map +1 -1
  41. package/dist/a2a/task-store.js +54 -0
  42. package/dist/a2a/task-store.js.map +1 -1
  43. package/dist/client/session-replay.d.ts.map +1 -1
  44. package/dist/client/session-replay.js +71 -9
  45. package/dist/client/session-replay.js.map +1 -1
  46. package/dist/collab/routes.d.ts +1 -1
  47. package/dist/collab/struct-routes.d.ts +1 -1
  48. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  49. package/dist/integrations/adapters/slack.d.ts.map +1 -1
  50. package/dist/integrations/adapters/slack.js +94 -11
  51. package/dist/integrations/adapters/slack.js.map +1 -1
  52. package/dist/integrations/identity.d.ts +19 -2
  53. package/dist/integrations/identity.d.ts.map +1 -1
  54. package/dist/integrations/identity.js +76 -34
  55. package/dist/integrations/identity.js.map +1 -1
  56. package/dist/integrations/pending-tasks-retry-job.d.ts.map +1 -1
  57. package/dist/integrations/pending-tasks-retry-job.js +2 -1
  58. package/dist/integrations/pending-tasks-retry-job.js.map +1 -1
  59. package/dist/integrations/pending-tasks-store.js +1 -1
  60. package/dist/integrations/pending-tasks-store.js.map +1 -1
  61. package/dist/integrations/plugin.d.ts.map +1 -1
  62. package/dist/integrations/plugin.js +165 -9
  63. package/dist/integrations/plugin.js.map +1 -1
  64. package/dist/integrations/types.d.ts +32 -0
  65. package/dist/integrations/types.d.ts.map +1 -1
  66. package/dist/integrations/types.js.map +1 -1
  67. package/dist/integrations/webhook-handler.js +1 -0
  68. package/dist/integrations/webhook-handler.js.map +1 -1
  69. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  70. package/dist/mcp/builtin-tools.js +5 -2
  71. package/dist/mcp/builtin-tools.js.map +1 -1
  72. package/dist/notifications/routes.d.ts +1 -1
  73. package/dist/progress/routes.d.ts +1 -1
  74. package/dist/resources/handlers.d.ts +3 -3
  75. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  76. package/dist/server/transcribe-voice.d.ts +1 -1
  77. package/docs/content/external-agents.mdx +22 -12
  78. package/docs/content/locales/ar-SA/external-agents.mdx +8 -2
  79. package/docs/content/locales/de-DE/external-agents.mdx +8 -2
  80. package/docs/content/locales/es-ES/external-agents.mdx +8 -2
  81. package/docs/content/locales/fr-FR/external-agents.mdx +8 -2
  82. package/docs/content/locales/hi-IN/external-agents.mdx +8 -2
  83. package/docs/content/locales/ja-JP/external-agents.mdx +8 -2
  84. package/docs/content/locales/ko-KR/external-agents.mdx +8 -2
  85. package/docs/content/locales/pt-BR/external-agents.mdx +8 -2
  86. package/docs/content/locales/zh-CN/external-agents.mdx +8 -2
  87. package/docs/content/locales/zh-TW/external-agents.mdx +8 -2
  88. package/package.json +1 -1
@@ -65,6 +65,8 @@ interface RrwebRecordModule {
65
65
  interface SessionReplayState {
66
66
  active: boolean;
67
67
  startPromise: Promise<SessionReplayStartResult> | null;
68
+ /** Invalidates deferred recorder startup when stop is requested mid-start. */
69
+ startGeneration: number;
68
70
  replayId: string | null;
69
71
  startedAtMs: number | null;
70
72
  sequence: number;
@@ -72,6 +74,8 @@ interface SessionReplayState {
72
74
  queue: QueuedReplayEvent[];
73
75
  queuedBytes: number;
74
76
  retryBatches: QueuedReplayEvent[][];
77
+ /** Consecutive retryable 4xx responses for the current recording episode. */
78
+ transientClientErrorFailures: number;
75
79
  flushTimer: number | null;
76
80
  maxDurationTimer: number | null;
77
81
  flushing: boolean;
@@ -432,12 +436,14 @@ function getState(): SessionReplayState {
432
436
  g[SESSION_REPLAY_STATE_KEY] = {
433
437
  active: false,
434
438
  startPromise: null,
439
+ startGeneration: 0,
435
440
  replayId: null,
436
441
  startedAtMs: null,
437
442
  sequence: 0,
438
443
  queue: [],
439
444
  queuedBytes: 0,
440
445
  retryBatches: [],
446
+ transientClientErrorFailures: 0,
441
447
  flushTimer: null,
442
448
  maxDurationTimer: null,
443
449
  flushing: false,
@@ -460,10 +466,12 @@ function getState(): SessionReplayState {
460
466
  const state = g[SESSION_REPLAY_STATE_KEY]!;
461
467
  // Keep Vite HMR safe when an older recorder state survives a module reload.
462
468
  state.resourceNodes ??= new Map();
469
+ state.transientClientErrorFailures ??= 0;
463
470
  state.restoreIframeBridge ??= null;
464
471
  state.pendingFlushReason ??= null;
465
472
  state.pendingFlushWaiters ??= [];
466
473
  state.awaitingFullSnapshot ??= false;
474
+ state.startGeneration ??= 0;
467
475
  return state;
468
476
  }
469
477
 
@@ -1452,12 +1460,19 @@ class ReplayUploadHttpError extends Error {
1452
1460
  }
1453
1461
  }
1454
1462
 
1455
- /** 4xx statuses where retrying the exact same batch can never succeed --
1456
- * e.g. a 409 chunk-sequence/checksum conflict, or a 400 the server will
1457
- * reject again. 408 (timeout) and 429 (rate limit) are excluded because a
1458
- * later retry can plausibly succeed. */
1463
+ /** 4xx statuses where retrying the exact same batch can never succeed.
1464
+ * Keep this deliberately narrow: 401/403/404 can be temporary during auth or
1465
+ * deploy transitions, so they get a small retry budget before the rejected
1466
+ * episode is stopped. The budget prevents a persistent configuration failure
1467
+ * from pinning retryBatches while rrweb events grow without bound. */
1459
1468
  function isDefinitiveReplayUploadClientError(status: number): boolean {
1460
- return status >= 400 && status < 500 && status !== 408 && status !== 429;
1469
+ return status === 400 || status === 409 || status === 413 || status === 422;
1470
+ }
1471
+
1472
+ const MAX_TRANSIENT_REPLAY_CLIENT_FAILURES = 3;
1473
+
1474
+ function isTransientReplayUploadClientError(status: number): boolean {
1475
+ return status === 401 || status === 403 || status === 404;
1461
1476
  }
1462
1477
 
1463
1478
  async function sendReplayUpload(
@@ -1733,6 +1748,7 @@ export async function flushSessionReplay(reason = "manual"): Promise<void> {
1733
1748
  });
1734
1749
  if (!reservedSequence) advanceReplaySequence(state, payload);
1735
1750
  state.automaticConflictRestartAttempted = false;
1751
+ state.transientClientErrorFailures = 0;
1736
1752
  uploaded = true;
1737
1753
  } catch (error) {
1738
1754
  if (reservedSequence) rollbackReplaySequenceReservation(state, payload);
@@ -1745,9 +1761,22 @@ export async function flushSessionReplay(reason = "manual"): Promise<void> {
1745
1761
  const splitBatch = rejectedStatus === 413 ? splitReplayBatch(events) : null;
1746
1762
  const isUnsplittableOversizedBatch =
1747
1763
  rejectedStatus === 413 && splitBatch === null;
1764
+ const isTransientClientError =
1765
+ rejectedStatus !== null &&
1766
+ isTransientReplayUploadClientError(rejectedStatus);
1767
+ if (isTransientClientError) {
1768
+ state.transientClientErrorFailures += 1;
1769
+ } else {
1770
+ state.transientClientErrorFailures = 0;
1771
+ }
1772
+ const exhaustedTransientClientRetries =
1773
+ isTransientClientError &&
1774
+ state.transientClientErrorFailures >=
1775
+ MAX_TRANSIENT_REPLAY_CLIENT_FAILURES;
1748
1776
  const isDefinitiveClientError =
1749
1777
  error instanceof ReplayUploadHttpError &&
1750
- isDefinitiveReplayUploadClientError(error.status) &&
1778
+ (isDefinitiveReplayUploadClientError(error.status) ||
1779
+ exhaustedTransientClientRetries) &&
1751
1780
  !splitBatch &&
1752
1781
  !isUnsplittableOversizedBatch;
1753
1782
  if (splitBatch) {
@@ -1903,6 +1932,31 @@ async function restartSessionReplayAfterConflict(
1903
1932
  state: SessionReplayState,
1904
1933
  options: NormalizedSessionReplayOptions,
1905
1934
  sessionId: string,
1935
+ ): Promise<SessionReplayStartResult> {
1936
+ // Share the public-start mutex. A consumer may call start in the await gap
1937
+ // after the rejected recorder stops; both paths must converge on one rrweb
1938
+ // instance rather than racing two fresh identities.
1939
+ if (state.startPromise) return state.startPromise;
1940
+
1941
+ const startGeneration = ++state.startGeneration;
1942
+ let startPromise: Promise<SessionReplayStartResult>;
1943
+ startPromise = restartSessionReplayAfterConflictInternal(
1944
+ state,
1945
+ options,
1946
+ sessionId,
1947
+ startGeneration,
1948
+ ).finally(() => {
1949
+ if (state.startPromise === startPromise) state.startPromise = null;
1950
+ });
1951
+ state.startPromise = startPromise;
1952
+ return startPromise;
1953
+ }
1954
+
1955
+ async function restartSessionReplayAfterConflictInternal(
1956
+ state: SessionReplayState,
1957
+ options: NormalizedSessionReplayOptions,
1958
+ sessionId: string,
1959
+ startGeneration: number,
1906
1960
  ): Promise<SessionReplayStartResult> {
1907
1961
  if (options.shouldStart && !options.shouldStart()) {
1908
1962
  return { started: false, reason: "disabled", sessionId, sampled: true };
@@ -1932,6 +1986,7 @@ async function restartSessionReplayAfterConflict(
1932
1986
  sessionId,
1933
1987
  true,
1934
1988
  initialProperties,
1989
+ startGeneration,
1935
1990
  );
1936
1991
  }
1937
1992
 
@@ -2879,6 +2934,12 @@ export async function startSessionReplay(
2879
2934
  }
2880
2935
  if (state.startPromise) return state.startPromise;
2881
2936
 
2937
+ // This is a new caller-initiated recording episode. A prior episode's
2938
+ // conflict-loop guard must not prevent this one from recovering once.
2939
+ state.automaticConflictRestartAttempted = false;
2940
+ state.transientClientErrorFailures = 0;
2941
+ const startGeneration = ++state.startGeneration;
2942
+
2882
2943
  let startPromise: Promise<SessionReplayStartResult>;
2883
2944
  startPromise = startSessionReplayRecorder(
2884
2945
  state,
@@ -2886,6 +2947,7 @@ export async function startSessionReplay(
2886
2947
  sessionId,
2887
2948
  sampled,
2888
2949
  initialProperties,
2950
+ startGeneration,
2889
2951
  ).finally(() => {
2890
2952
  if (state.startPromise === startPromise) {
2891
2953
  state.startPromise = null;
@@ -2901,6 +2963,7 @@ async function startSessionReplayRecorder(
2901
2963
  sessionId: string,
2902
2964
  sampled: boolean,
2903
2965
  initialProperties: Record<string, unknown> | undefined,
2966
+ startGeneration: number,
2904
2967
  ): Promise<SessionReplayStartResult> {
2905
2968
  if (state.active && state.replayId) {
2906
2969
  return {
@@ -2918,6 +2981,9 @@ async function startSessionReplayRecorder(
2918
2981
  } catch {
2919
2982
  return { started: false, reason: "import-failed", sessionId, sampled };
2920
2983
  }
2984
+ if (state.startGeneration !== startGeneration) {
2985
+ return { started: false, reason: "disabled", sessionId, sampled };
2986
+ }
2921
2987
  if (normalized.shouldStart && !normalized.shouldStart()) {
2922
2988
  return { started: false, reason: "disabled", sessionId, sampled };
2923
2989
  }
@@ -2950,6 +3016,20 @@ async function startSessionReplayRecorder(
2950
3016
  };
2951
3017
  }
2952
3018
  }
3019
+ // stopSessionReplay may be called while the duplicate-tab probe is waiting.
3020
+ // Recheck both cancellation and the caller's live eligibility before rrweb
3021
+ // is activated so a deferred start cannot escape a route/auth teardown.
3022
+ if (
3023
+ state.startGeneration !== startGeneration ||
3024
+ (normalized.shouldStart && !normalized.shouldStart())
3025
+ ) {
3026
+ try {
3027
+ replayChannel?.close();
3028
+ } catch {
3029
+ // best-effort cleanup
3030
+ }
3031
+ return { started: false, reason: "disabled", sessionId, sampled };
3032
+ }
2953
3033
  state.options = normalized;
2954
3034
  state.replayId = replaySession.replayId;
2955
3035
  state.startedAtMs = replaySession.startedAtMs;
@@ -3049,6 +3129,10 @@ async function startSessionReplayRecorder(
3049
3129
 
3050
3130
  export async function stopSessionReplay(reason = "manual"): Promise<void> {
3051
3131
  const state = getState();
3132
+ // Invalidate an import/probe that has not set `active` yet. Without this,
3133
+ // stop during the duplicated-tab claim window was a no-op and rrweb started
3134
+ // after the caller believed recording had been disabled.
3135
+ state.startGeneration += 1;
3052
3136
  if (!state.active) return;
3053
3137
  // Restore console/fetch/XHR before tearing down the recorder: the restore
3054
3138
  // flushes any pending collapsed console duplicate, which must still be able
@@ -56,6 +56,31 @@ const slackIdentityCache = new Map<
56
56
  { identity: SlackUserIdentity | null; expiresAt: number }
57
57
  >();
58
58
 
59
+ // Deduped system notices send at most once per key per TTL so senders are
60
+ // informed without being spammed per message. Callers pick the window: the
61
+ // anonymous-tier heads-up uses the default day-long TTL; decline replies pass
62
+ // a short TTL so a persistent condition still reminds the sender occasionally.
63
+ const SLACK_SYSTEM_NOTICE_DEDUPE_TTL_MS = 24 * 60 * 60 * 1_000;
64
+ const SLACK_SYSTEM_NOTICE_CACHE_MAX_ENTRIES = 1_000;
65
+ const slackSystemNoticeCache = new Map<string, number>();
66
+
67
+ /** Returns true when a deduped notice should send now, claiming the slot. */
68
+ function claimSlackSystemNoticeSlot(
69
+ key: string,
70
+ ttlMs = SLACK_SYSTEM_NOTICE_DEDUPE_TTL_MS,
71
+ ): boolean {
72
+ const now = Date.now();
73
+ const expiresAt = slackSystemNoticeCache.get(key);
74
+ if (expiresAt && expiresAt > now) return false;
75
+ if (expiresAt) slackSystemNoticeCache.delete(key);
76
+ if (slackSystemNoticeCache.size >= SLACK_SYSTEM_NOTICE_CACHE_MAX_ENTRIES) {
77
+ const oldestKey = slackSystemNoticeCache.keys().next().value;
78
+ if (oldestKey) slackSystemNoticeCache.delete(oldestKey);
79
+ }
80
+ slackSystemNoticeCache.set(key, now + ttlMs);
81
+ return true;
82
+ }
83
+
59
84
  export interface SlackAdapterOptions {
60
85
  /** Resolve the bot token for the exact Slack installation. */
61
86
  resolveBotToken?: (incoming: IncomingMessage) => Promise<string | undefined>;
@@ -264,7 +289,10 @@ export function slackAdapter(
264
289
  triggerKind: isDm ? "dm" : isMention ? "mention" : "thread_reply",
265
290
  conversationType: isDm ? "dm" : "unknown",
266
291
  tenantId: teamId,
267
- actorTrust: { memberType: "unknown", verified: true },
292
+ // The signed Slack envelope authenticates the workspace, not the
293
+ // sender's membership tier. `users.info` hydration below is the
294
+ // authority that may promote this to a verified member.
295
+ actorTrust: { memberType: "unknown", verified: false },
268
296
  platformContext: {
269
297
  channelId: e.channel,
270
298
  channelType: e.channel_type,
@@ -493,6 +521,48 @@ export function slackAdapter(
493
521
  }
494
522
  },
495
523
 
524
+ async sendSystemNotice(
525
+ incoming: IncomingMessage,
526
+ text: string,
527
+ opts?: { dedupeKey?: string; dedupeTtlMs?: number },
528
+ ): Promise<void> {
529
+ if (!text.trim()) return;
530
+ const dedupeKey = opts?.dedupeKey;
531
+ if (
532
+ dedupeKey &&
533
+ !claimSlackSystemNoticeSlot(dedupeKey, opts?.dedupeTtlMs)
534
+ ) {
535
+ return;
536
+ }
537
+ try {
538
+ const token = await resolveBotToken(incoming);
539
+ if (!token) {
540
+ if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
541
+ throw new Error("Slack bot token not configured for system notice");
542
+ }
543
+ const channelId = incoming.platformContext.channelId;
544
+ if (typeof channelId !== "string" || !channelId) {
545
+ if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
546
+ throw new Error("Slack channel id missing for system notice");
547
+ }
548
+ const threadTs =
549
+ typeof incoming.platformContext.threadTs === "string"
550
+ ? incoming.platformContext.threadTs
551
+ : undefined;
552
+ await postFresh(token, channelId, threadTs, {
553
+ text,
554
+ unfurl_links: false,
555
+ unfurl_media: false,
556
+ mrkdwn: true,
557
+ });
558
+ } catch (error) {
559
+ // A failed delivery did not inform the sender, so release the slot and
560
+ // allow the next message to retry instead of suppressing notices for a day.
561
+ if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
562
+ throw error;
563
+ }
564
+ },
565
+
496
566
  async sendMessageToTarget(
497
567
  message: OutgoingMessage,
498
568
  target: OutboundTarget,
@@ -1137,15 +1207,18 @@ async function resolveSlackUserIdentity(
1137
1207
  : typeof user.user.name === "string" && user.user.name.trim()
1138
1208
  ? user.user.name.trim()
1139
1209
  : null,
1140
- memberType: user.user.is_ultra_restricted
1141
- ? "external"
1142
- : user.user.is_restricted
1143
- ? "guest"
1144
- : user.user.is_owner
1145
- ? "owner"
1146
- : user.user.is_admin
1147
- ? "admin"
1148
- : "member",
1210
+ // is_stranger marks Slack Connect DM participants from another
1211
+ // workspace; they must map to "external", never "member".
1212
+ memberType:
1213
+ user.user.is_stranger || user.user.is_ultra_restricted
1214
+ ? "external"
1215
+ : user.user.is_restricted
1216
+ ? "guest"
1217
+ : user.user.is_owner
1218
+ ? "owner"
1219
+ : user.user.is_admin
1220
+ ? "admin"
1221
+ : "member",
1149
1222
  }
1150
1223
  : null;
1151
1224
  if (slackIdentityCache.size >= SLACK_IDENTITY_CACHE_MAX_ENTRIES) {
@@ -1168,7 +1241,24 @@ async function hydrateSlackIdentity(
1168
1241
  incoming: IncomingMessage,
1169
1242
  ): Promise<IncomingMessage> {
1170
1243
  const identity = await resolveSlackUserIdentity(token, incoming);
1171
- if (!identity) return incoming;
1244
+ if (!identity) {
1245
+ // Context hydration can call users.info again after the webhook identity
1246
+ // pass. A transient failure on that second lookup is not evidence that a
1247
+ // previously verified sender became unverified; preserve the stronger
1248
+ // identity instead of replacing it with a negative-cache result.
1249
+ if (
1250
+ incoming.senderVerified === true &&
1251
+ incoming.senderEmail?.trim() &&
1252
+ incoming.actorTrust?.verified === true
1253
+ ) {
1254
+ return incoming;
1255
+ }
1256
+ return {
1257
+ ...incoming,
1258
+ senderVerified: false,
1259
+ actorTrust: { memberType: "unknown", verified: false },
1260
+ };
1261
+ }
1172
1262
  return {
1173
1263
  ...incoming,
1174
1264
  ...(identity.name ? { senderName: identity.name } : {}),
@@ -1299,8 +1389,6 @@ async function hydrateSlackContext(
1299
1389
  : [];
1300
1390
 
1301
1391
  const profile = identity;
1302
- const isGuest = identity?.memberType === "guest";
1303
- const isExternal = identity?.memberType === "external";
1304
1392
  const isPrivate = conversation?.channel?.is_private === true;
1305
1393
  const isExternalShared = conversation?.channel?.is_ext_shared === true;
1306
1394
  const isMpim = conversation?.channel?.is_mpim === true;
@@ -1348,12 +1436,15 @@ async function hydrateSlackContext(
1348
1436
  ? { senderEmail: profile.email, senderVerified: true }
1349
1437
  : { senderVerified: false }),
1350
1438
  conversationType,
1351
- actorTrust: {
1352
- memberType:
1353
- identity?.memberType ??
1354
- (isExternal ? "external" : isGuest ? "guest" : "member"),
1355
- verified: true,
1356
- },
1439
+ // Conversation hydration must not promote a caller when users.info was
1440
+ // unavailable. Preserve the result of the earlier identity-only hydration
1441
+ // unless this request independently resolved the Slack user.
1442
+ actorTrust: identity
1443
+ ? { memberType: identity.memberType, verified: true }
1444
+ : (incoming.actorTrust ?? {
1445
+ memberType: "unknown",
1446
+ verified: false,
1447
+ }),
1357
1448
  contextMessages: hydratedMessages,
1358
1449
  ...(hydratedFiles.length ? { files: hydratedFiles.slice(0, 20) } : {}),
1359
1450
  platformContext: {
@@ -7,6 +7,34 @@ import {
7
7
  import { slackInstallationKey } from "./slack-oauth.js";
8
8
  import type { IncomingMessage, IntegrationExecutionContext } from "./types.js";
9
9
 
10
+ export type IntegrationIdentityDeclineReason =
11
+ | "unverified"
12
+ | "guest"
13
+ | "unlinked-workspace"
14
+ | "membership-check-failed";
15
+
16
+ /**
17
+ * Thrown when the default Slack DM identity ladder declines to run a message.
18
+ * `reason` is a stable machine-readable discriminator (used e.g. to dedupe
19
+ * decline replies); `userFacingMessage` is safe to send back to the sender as
20
+ * a polite reply; `message` stays log-only.
21
+ */
22
+ export class IntegrationIdentityDeclinedError extends Error {
23
+ readonly reason: IntegrationIdentityDeclineReason;
24
+ readonly userFacingMessage: string;
25
+
26
+ constructor(
27
+ reason: IntegrationIdentityDeclineReason,
28
+ message: string,
29
+ userFacingMessage: string,
30
+ ) {
31
+ super(message);
32
+ this.name = "IntegrationIdentityDeclinedError";
33
+ this.reason = reason;
34
+ this.userFacingMessage = userFacingMessage;
35
+ }
36
+ }
37
+
10
38
  function serviceOwner(platform: string): string {
11
39
  return `integration@${platform}`;
12
40
  }
@@ -47,17 +75,13 @@ async function resolveSlackInstallation(incoming: IncomingMessage) {
47
75
  }
48
76
 
49
77
  async function isMemberOfOrg(email: string, orgId: string): Promise<boolean> {
50
- try {
51
- const { rows } = await getDbExec().execute({
52
- sql: `SELECT 1 FROM org_members
78
+ const { rows } = await getDbExec().execute({
79
+ sql: `SELECT 1 FROM org_members
53
80
  WHERE org_id = ? AND LOWER(email) = ?
54
81
  LIMIT 1`,
55
- args: [orgId, email],
56
- });
57
- return rows.length > 0;
58
- } catch {
59
- return false;
60
- }
82
+ args: [orgId, email],
83
+ });
84
+ return rows.length > 0;
61
85
  }
62
86
 
63
87
  /**
@@ -65,8 +89,13 @@ async function isMemberOfOrg(email: string, orgId: string): Promise<boolean> {
65
89
  *
66
90
  * Slack DMs become a user principal only after the adapter has verified the
67
91
  * sender email and the email is already a member of the managed installation's
68
- * Agent Native organization. Shared channels deliberately stay service-scoped
69
- * so a channel message cannot borrow one participant's private permissions.
92
+ * Agent Native organization. Hydrated full workspace members whose email is
93
+ * missing, unverified, or not an organization member run as an anonymous
94
+ * org-scoped service principal instead — the same visibility shared channels
95
+ * get. Hydration failures, guests/external members, and workspaces without a
96
+ * connected organization are declined with a user-facing message. Shared
97
+ * channels deliberately stay service-scoped so a channel message cannot borrow
98
+ * one participant's private permissions.
70
99
  */
71
100
  export async function resolveDefaultIntegrationExecutionContext(
72
101
  incoming: IncomingMessage,
@@ -85,48 +114,83 @@ export async function resolveDefaultIntegrationExecutionContext(
85
114
  };
86
115
  }
87
116
 
88
- const email = normalizedEmail(incoming.senderEmail);
89
- if (
90
- !email ||
91
- incoming.senderVerified !== true ||
92
- !incoming.senderId ||
93
- !incoming.tenantId
94
- ) {
95
- throw new Error(
96
- "Slack DM sender identity could not be verified; refusing to run with a service principal.",
117
+ // Hydration check first: a transient users.info failure must land on the
118
+ // retry decline here, never on the anonymous org-scoped tier below.
119
+ if (incoming.actorTrust?.verified !== true) {
120
+ throw new IntegrationIdentityDeclinedError(
121
+ "unverified",
122
+ "Slack DM sender identity could not be hydrated; declining instead of guessing a principal.",
123
+ "I couldn't verify your Slack identity just now, so I can't run this request. Please try again in a moment.",
97
124
  );
98
125
  }
99
126
  if (
100
- incoming.actorTrust?.memberType === "guest" ||
101
- incoming.actorTrust?.memberType === "external"
127
+ incoming.actorTrust.memberType === "guest" ||
128
+ incoming.actorTrust.memberType === "external"
102
129
  ) {
103
- throw new Error(
104
- "External or guest Slack members cannot use personal Agent Native permissions.",
130
+ throw new IntegrationIdentityDeclinedError(
131
+ "guest",
132
+ "External or guest Slack members cannot use this integration.",
133
+ "This assistant is only available to members of this workspace's organization.",
105
134
  );
106
135
  }
107
136
  if (!installation?.orgId) {
108
- throw new Error(
137
+ throw new IntegrationIdentityDeclinedError(
138
+ "unlinked-workspace",
109
139
  "Slack workspace is not connected to an Agent Native organization.",
140
+ "This Slack workspace isn't connected to an organization yet.",
110
141
  );
111
142
  }
112
143
 
113
- if (!(await isMemberOfOrg(email, installation.orgId))) {
114
- throw new Error(
115
- "Slack DM sender is not a member of the connected Agent Native organization.",
116
- );
144
+ const email = normalizedEmail(incoming.senderEmail);
145
+ let isOrgMember = false;
146
+ if (
147
+ email &&
148
+ incoming.senderVerified === true &&
149
+ incoming.senderId &&
150
+ incoming.tenantId
151
+ ) {
152
+ try {
153
+ isOrgMember = await isMemberOfOrg(email, installation.orgId);
154
+ } catch {
155
+ // A membership-store outage is not evidence that the sender is merely
156
+ // unlinked. Fail closed instead of widening them to org-wide access.
157
+ throw new IntegrationIdentityDeclinedError(
158
+ "membership-check-failed",
159
+ "Slack DM organization membership could not be verified.",
160
+ "I couldn't verify your organization membership just now, so I can't run this request. Please try again in a moment.",
161
+ );
162
+ }
163
+ }
164
+ if (isOrgMember && email && incoming.senderId && incoming.tenantId) {
165
+ const link = await upsertVerifiedIntegrationIdentity({
166
+ platform: incoming.platform,
167
+ tenantId: incoming.tenantId,
168
+ externalUserId: incoming.senderId,
169
+ userEmail: email,
170
+ orgId: installation.orgId,
171
+ });
172
+ return {
173
+ ownerEmail: link.userEmail,
174
+ orgId: link.orgId,
175
+ principalType: "user",
176
+ installationId: installation.id,
177
+ };
117
178
  }
118
179
 
119
- const link = await upsertVerifiedIntegrationIdentity({
120
- platform: incoming.platform,
121
- tenantId: incoming.tenantId,
122
- externalUserId: incoming.senderId,
123
- userEmail: email,
124
- orgId: installation.orgId,
125
- });
180
+ // Hydrated full workspace member (member/admin/owner) whose email is
181
+ // unverified, missing (legacy install without users:read.email), or not an
182
+ // organization member: run with the anonymous org-scoped service principal —
183
+ // the same visibility shared channels get. Nothing user-private is
184
+ // accessible. One structured line keeps 100%-anonymous workspaces (legacy
185
+ // scope missing) visible in logs.
186
+ console.warn(
187
+ `[integrations] anonymous org-scoped principal used: platform=${incoming.platform} teamId=${incoming.tenantId ?? "unknown"} emailPresent=${Boolean(email)} memberType=${incoming.actorTrust.memberType}`,
188
+ );
126
189
  return {
127
- ownerEmail: link.userEmail,
128
- orgId: link.orgId,
129
- principalType: "user",
190
+ ownerEmail: serviceOwner(incoming.platform),
191
+ orgId: installation.orgId,
192
+ principalType: "service",
130
193
  installationId: installation.id,
194
+ anonymousMember: true,
131
195
  };
132
196
  }
@@ -115,7 +115,8 @@ export async function retryStuckPendingTasks(
115
115
  SET status = 'failed',
116
116
  updated_at = ?,
117
117
  error_message = COALESCE(error_message, ?),
118
- payload = '{}'
118
+ payload = '{}',
119
+ external_event_key = NULL
119
120
  WHERE id = ?
120
121
  AND status = ?
121
122
  `,
@@ -364,7 +364,7 @@ export async function markTaskFailed(
364
364
  const now = Date.now();
365
365
  await client.execute({
366
366
  sql: `UPDATE integration_pending_tasks
367
- SET status = ?, updated_at = ?, error_message = ?, payload = ?
367
+ SET status = ?, updated_at = ?, error_message = ?, payload = ?, external_event_key = NULL
368
368
  WHERE id = ?`,
369
369
  args: ["failed", now, errorMessage.slice(0, 2000), "{}", id],
370
370
  });