@opengeni/api-router 0.30.3 → 2.1.0-canary.0

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 (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-QKDFBBUE.js} +26385 -15573
  5. package/dist/chunk-QKDFBBUE.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +149 -9
  32. package/dist/integrations/slack-interactions.d.ts +4 -3
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +153 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +337 -57
  102. package/src/integrations/slack-interactions.ts +1265 -208
  103. package/src/integrations/slack-routing.ts +324 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +221 -3
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -7,6 +7,7 @@ import {
7
7
  ListSlackUserLinkAccessRequestsResponse,
8
8
  PrepareSlackUserLinkAccessRequest,
9
9
  evaluateSlackTaskPolicy,
10
+ resolveWorkspaceSlackOrchestrationNoticeSettings,
10
11
  resolveWorkspaceSlackReactionSummonSettings,
11
12
  SlackReactionChannelListResponse,
12
13
  SlackUserLinkAccessMutationRequest,
@@ -15,6 +16,9 @@ import {
15
16
  type FileResourceRef,
16
17
  type FirstPartyMcpToolName,
17
18
  type HumanInputQuestion,
19
+ type ResolvedWorkspaceSlackOrchestrationNoticeSettings,
20
+ type ChildRequiresActionPayload,
21
+ type SessionAuthorizationListScope,
18
22
  type SessionEvent,
19
23
  type WorkspaceSlackReactionSummonSettings,
20
24
  workspaceSlackReactionChannelAllowed,
@@ -31,13 +35,16 @@ import {
31
35
  advanceSlackInteractionDelivery,
32
36
  bindSlackInteractionSession,
33
37
  cancelSlackUserLinkAccessRequest,
38
+ claimSlackAppHomeRefresh,
34
39
  claimSlackInteractionDelivery,
35
40
  claimSlackInteractionProgressDelivery,
36
41
  claimSlackInteractionInbox,
37
42
  closeSlackInteractionDelivery,
38
43
  completeSlackUserLinkAccessIfGranted,
44
+ decodeSessionListCursor,
39
45
  deferSlackInteractionDelivery,
40
46
  deleteSlackBotUserLink,
47
+ enqueueSlackAppHomeRefresh,
41
48
  enqueueSlackInteractionInbox,
42
49
  getConnectionMetadata,
43
50
  getOrCreateSlackInteraction,
@@ -45,17 +52,24 @@ import {
45
52
  getSession,
46
53
  getSessionEvent,
47
54
  getSessionHumanInputRequest,
55
+ childRequiresActionResolutionExists,
56
+ getSessionSystemUpdateById,
48
57
  getSlackBotPostOperation,
49
58
  getSlackBotUserLink,
50
59
  getSlackInteractionActionHandle,
51
60
  getSlackInteractionByClientEventId,
52
61
  getSlackInteractionById,
53
- getSlackInteractionByRoute,
62
+ getSlackInteractionByConnectionRoute,
63
+ probeSlackActionHandleTenancy,
64
+ getSlackChannelRoute,
65
+ getSlackUserDmRoute,
66
+ listNamedSubjectSlackRoutableWorkspaces,
54
67
  getActiveSlackTaskPolicy,
55
68
  getSlackSharedTaskOrigin,
56
69
  getSessionEventByClientEventId,
57
70
  getWorkspace,
58
71
  getWorkspaceGrant,
72
+ resolveSlackTargetAuthority,
59
73
  listSlackInteractionProgressDeliveryEvidence,
60
74
  listSessionEventPage,
61
75
  listSessionHumanInputRequests,
@@ -63,18 +77,25 @@ import {
63
77
  rekeySlackInteractionRoute,
64
78
  reopenSlackInteractionDelivery,
65
79
  reserveSlackInteractionActionHandles,
80
+ renewSlackAppHomeRefreshClaim,
66
81
  releaseSlackInteractionDelivery,
82
+ releaseSlackAppHomeRefresh,
67
83
  releaseSlackInteractionInbox,
68
84
  requestSlackUserLinkWorkspaceAccess,
69
85
  resolveSlackInstallationRoute,
86
+ resolveSlackInteractionFirstTaskHint,
70
87
  saveSlackInteractionInboxReactionCheckpoint,
71
88
  saveSlackSharedTaskOrigin,
72
89
  settleSlackInteractionInbox,
90
+ settleSlackAppHomeRefresh,
73
91
  settleSlackInteractionActionHandles,
92
+ listSessionsForSubject,
93
+ SessionListAccessError,
74
94
  denySlackUserLinkAccessRequest,
75
95
  prepareSlackUserLinkAccessRequest,
76
96
  SlackUserLinkAccessPersistenceError,
77
97
  type SlackInstallationRoute,
98
+ type SlackAppHomeRefresh,
78
99
  type SlackInteraction,
79
100
  type SlackInteractionActionHandle,
80
101
  type SlackInteractionActionKind,
@@ -88,6 +109,7 @@ import {
88
109
  hasPermission,
89
110
  requireAccessContext,
90
111
  requireAccessGrant,
112
+ requireSessionAuthorizationListScope,
91
113
  type ApiRouteDeps,
92
114
  } from "@opengeni/core";
93
115
  import { publishDurableSessionEvents } from "@opengeni/events";
@@ -100,6 +122,20 @@ import {
100
122
  type SlackMessageBlock,
101
123
  SlackBotProviderError,
102
124
  } from "./slack-bot";
125
+ import {
126
+ isSlackDirectMessageConversation,
127
+ resolveSlackWorkspaceRoute,
128
+ slackRoutedRequestText,
129
+ type SlackRouteResolution,
130
+ type SlackRouteTenancy,
131
+ } from "./slack-routing";
132
+ import {
133
+ buildSlackAppHomeAccessBlocks,
134
+ buildSlackAppHomeBlocks,
135
+ escapeSlackMrkdwn,
136
+ isSlackAppHomeLinkAction,
137
+ slackAppHomeOpenedEvent,
138
+ } from "./slack-app-home";
103
139
  import { importSlackReactionImage, type ImportedSlackReactionImage } from "../slack-reaction-files";
104
140
 
105
141
  export const SLACK_INTERACTION_MAX_BODY_BYTES = 256 * 1024;
@@ -112,6 +148,13 @@ export const SLACK_DELIVERY_EVENT_TYPES = [
112
148
  "turn.failed",
113
149
  "turn.cancelled",
114
150
  "session.status.changed",
151
+ // Orchestration surfacing. Both are filtered again inside the pump: the
152
+ // workspace must have opted in (both notices default off), and then only a
153
+ // `child_requires_action` notice and a `limits` / `max_auto_continuations`
154
+ // goal pause reach Slack, so the thread stays quiet for the deferred child
155
+ // lifecycle kinds and for human/API/agent pauses the human already made.
156
+ "system.update.pending",
157
+ "goal.paused",
115
158
  ] as const;
116
159
 
117
160
  const MAX_SLACK_TEXT_CHARS = 3_500;
@@ -125,6 +168,7 @@ const MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES = 16 * 1024 * 1024;
125
168
  const MAX_PROGRESS_MESSAGES = 3;
126
169
  const SLACK_USER_LINK_TTL_MS = 15 * 60_000;
127
170
  const INBOX_LEASE_MS = 30_000;
171
+ const APP_HOME_LEASE_MS = 300_000;
128
172
  const DELIVERY_LEASE_MS = 30_000;
129
173
  const MAX_DELIVERY_ATTEMPTS = 8;
130
174
  const MAX_DELIVERY_RETRY_MS = 5 * 60_000;
@@ -132,6 +176,23 @@ const SLACK_INTERACTION_BOT_SUBJECT_ID = "service:slack-interaction";
132
176
  const SLACK_ACTION_TTL_MS = 7 * 24 * 60 * 60_000;
133
177
  const MAX_SLACK_APPROVALS_PER_CARD = 8;
134
178
  const MAX_SLACK_ACTIONS_PER_CARD = 20;
179
+ /** Blocked-worker cards are pointers, so the question preview stays short. */
180
+ const MAX_SLACK_CHILD_DETAIL_CHARS = 240;
181
+ const SLACK_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
182
+ /**
183
+ * Goal pause reasons the Slack thread announces. `user_pause`, `api`, `agent`,
184
+ * and `no_progress` are deliberately absent.
185
+ *
186
+ * A Map, not a plain object: this lookup is the sole gate of a two-reason
187
+ * invariant, and a plain object indexed by a payload-derived string answers
188
+ * `constructor` (and every other prototype key) with a truthy value that would
189
+ * sail past the `if (!headline)` guard. No payload reaches it with such a
190
+ * reason today; the gate should not depend on that staying true.
191
+ */
192
+ const SLACK_GOAL_PAUSED_HEADLINES = new Map<string, string>([
193
+ ["limits", "Goal paused (budget)"],
194
+ ["max_auto_continuations", "Goal paused (continuation cap)"],
195
+ ]);
135
196
  /**
136
197
  * Slack delivery restrictions are durable session-level authority, not
137
198
  * attacker-adjacent user-message context. Migration 0240 backfills this exact
@@ -357,6 +418,19 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
357
418
  message: "Slack installation unavailable",
358
419
  });
359
420
  const event = record(payload.event);
421
+ const appHomeEvent = slackAppHomeOpenedEvent(payload);
422
+ if (appHomeEvent) {
423
+ await enqueueSlackAppHomeRefresh(deps.db, {
424
+ accountId: installation.accountId,
425
+ workspaceId: installation.workspaceId,
426
+ connectionId: installation.connectionId,
427
+ slackTeamId: appHomeEvent.slackTeamId,
428
+ slackUserId: appHomeEvent.slackUserId,
429
+ providerEventId: appHomeEvent.eventId,
430
+ providerViewHash: appHomeEvent.viewHash,
431
+ });
432
+ return c.json({ ok: true });
433
+ }
360
434
  if (event?.type === "reaction_added") {
361
435
  const [workspace, connection] = await Promise.all([
362
436
  getWorkspace(deps.db, installation.workspaceId),
@@ -383,7 +457,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
383
457
  app.post("/v1/integrations/slack/commands", async (c) => {
384
458
  const signed = await readSignedSlackRequest(c, deps);
385
459
  const form = new URLSearchParams(signed.rawBody);
386
- if (form.get("command") !== "/opengeni") {
460
+ if (form.get("command") !== deps.settings.slackCommand) {
387
461
  throw new HTTPException(400, { message: "invalid Slack command" });
388
462
  }
389
463
  const entry = normalizedFormInteraction(form, "slash_command");
@@ -392,6 +466,12 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
392
466
  throw new HTTPException(403, {
393
467
  message: "Slack installation unavailable",
394
468
  });
469
+ // `<command> info` is a read-only ephemeral explainer. It never reaches the
470
+ // durable inbox, never verifies channel membership, and never creates a
471
+ // session, so it is safe anywhere the command is available.
472
+ if (isSlackInfoCommand(entry.text)) {
473
+ return c.json(await slackInfoCommandResponse(deps, installation, entry));
474
+ }
395
475
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
396
476
  accountId: installation.accountId,
397
477
  workspaceId: installation.workspaceId,
@@ -424,6 +504,7 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
424
504
  const form = new URLSearchParams(signed.rawBody);
425
505
  const payload = parseJsonObject(form.get("payload") ?? "");
426
506
  if (payload.type === "block_actions") {
507
+ if (isSlackAppHomeLinkAction(payload)) return c.json({ ok: true });
427
508
  const entry = normalizedBlockActionInteraction(payload);
428
509
  const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
429
510
  if (!installation) {
@@ -783,7 +864,285 @@ export function registerSlackInteractionRoutes(app: Hono, deps: ApiRouteDeps): v
783
864
  });
784
865
  }
785
866
 
867
+ async function publishSlackAppHome(
868
+ deps: ApiRouteDeps,
869
+ installation: SlackInstallationRoute,
870
+ refresh: SlackAppHomeRefresh,
871
+ renewLease: () => Promise<void>,
872
+ ): Promise<void> {
873
+ const link = await getSlackBotUserLink(
874
+ deps.db,
875
+ installation.workspaceId,
876
+ installation.connectionId,
877
+ refresh.slackUserId,
878
+ );
879
+ const grant = link
880
+ ? await getWorkspaceGrant(deps.db, link.subjectId, installation.workspaceId, {
881
+ principalKind: "human_session",
882
+ })
883
+ : null;
884
+ if (
885
+ !grant ||
886
+ grant.accountId !== installation.accountId ||
887
+ !hasPermission(grant.permissions, "sessions:read")
888
+ ) {
889
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
890
+ accountId: installation.accountId,
891
+ workspaceId: installation.workspaceId,
892
+ connectionId: installation.connectionId,
893
+ subjectId: "service:slack-app-home",
894
+ });
895
+ await publishSlackAppHomeAccessView(
896
+ client,
897
+ refresh,
898
+ renewLease,
899
+ buildSlackAppHomeAccessBlocks({
900
+ title: link ? "OpenGeni access needed" : "Connect your OpenGeni account",
901
+ message: link
902
+ ? "Your Slack identity is linked, but it does not currently have access to this OpenGeni workspace."
903
+ : "Link this Slack identity to see your active tasks, requests, and recent results here.",
904
+ actionLabel: link ? "Request access" : "Connect OpenGeni",
905
+ actionUrl: slackAppHomeLinkUrl(
906
+ deps,
907
+ installation,
908
+ refresh.slackTeamId,
909
+ refresh.slackUserId,
910
+ ),
911
+ }),
912
+ );
913
+ return;
914
+ }
915
+
916
+ const client = await createOpenGeniSlackBotInteractionClient(deps, {
917
+ accountId: installation.accountId,
918
+ workspaceId: installation.workspaceId,
919
+ connectionId: installation.connectionId,
920
+ subjectId: grant.subjectId,
921
+ });
922
+ // Host session authorization is resolved at publication time, before the
923
+ // database query, so App Home cannot leak rows through a stale broad list.
924
+ const authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "core");
925
+ const sessions: Awaited<ReturnType<typeof listSessionsForSubject>>["sessions"] = [];
926
+ try {
927
+ let cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
928
+ do {
929
+ await renewLease();
930
+ const page = await listSessionsForSubject(deps.db, installation.workspaceId, {
931
+ subjectId: grant.subjectId,
932
+ limit: 500,
933
+ ...(cursor ? { cursor } : {}),
934
+ ...(authorizationScope ? { authorizationScope } : {}),
935
+ });
936
+ if (!cursor) sessions.push(...page.pinned);
937
+ sessions.push(...page.sessions);
938
+ if (!page.nextCursor) break;
939
+ cursor = decodeSessionListCursor(page.nextCursor) ?? undefined;
940
+ if (!cursor) throw new Error("Slack App Home session cursor was invalid");
941
+ } while (cursor);
942
+ } catch (error) {
943
+ if (!(error instanceof SessionListAccessError)) throw error;
944
+ await publishSlackAppHomeAccessView(
945
+ client,
946
+ refresh,
947
+ renewLease,
948
+ buildSlackAppHomeAccessBlocks({
949
+ title: "OpenGeni access changed",
950
+ message:
951
+ "Your current OpenGeni access could not be verified. Reconnect before tasks are shown here.",
952
+ actionLabel: "Reconnect OpenGeni",
953
+ actionUrl: slackAppHomeLinkUrl(
954
+ deps,
955
+ installation,
956
+ refresh.slackTeamId,
957
+ refresh.slackUserId,
958
+ ),
959
+ }),
960
+ );
961
+ return;
962
+ }
963
+ const currentLink = await getSlackBotUserLink(
964
+ deps.db,
965
+ installation.workspaceId,
966
+ installation.connectionId,
967
+ refresh.slackUserId,
968
+ );
969
+ const currentGrant = currentLink
970
+ ? await getWorkspaceGrant(deps.db, currentLink.subjectId, installation.workspaceId, {
971
+ principalKind: "human_session",
972
+ })
973
+ : null;
974
+ if (
975
+ !currentLink ||
976
+ currentLink.subjectId !== grant.subjectId ||
977
+ !currentGrant ||
978
+ currentGrant.accountId !== installation.accountId ||
979
+ !hasPermission(currentGrant.permissions, "sessions:read")
980
+ ) {
981
+ await publishSlackAppHomeAccessView(
982
+ client,
983
+ refresh,
984
+ renewLease,
985
+ buildSlackAppHomeAccessBlocks({
986
+ title: "OpenGeni access changed",
987
+ message:
988
+ "Your current OpenGeni access could not be verified. Reconnect before tasks are shown here.",
989
+ actionLabel: "Reconnect OpenGeni",
990
+ actionUrl: slackAppHomeLinkUrl(
991
+ deps,
992
+ installation,
993
+ refresh.slackTeamId,
994
+ refresh.slackUserId,
995
+ ),
996
+ }),
997
+ );
998
+ return;
999
+ }
1000
+ const currentAuthorizationScope = await requireSessionAuthorizationListScope(
1001
+ deps,
1002
+ currentGrant,
1003
+ "core",
1004
+ );
1005
+ if (
1006
+ slackSessionAuthorizationScopeKey(currentAuthorizationScope) !==
1007
+ slackSessionAuthorizationScopeKey(authorizationScope)
1008
+ ) {
1009
+ await publishSlackAppHomeAccessView(
1010
+ client,
1011
+ refresh,
1012
+ renewLease,
1013
+ buildSlackAppHomeAccessBlocks({
1014
+ title: "OpenGeni access changed",
1015
+ message:
1016
+ "Your current task access changed while this view was loading. Reopen Home to refresh it safely.",
1017
+ actionLabel: "Open OpenGeni",
1018
+ actionUrl: slackWorkspaceUrl(deps, installation.workspaceId),
1019
+ }),
1020
+ );
1021
+ return;
1022
+ }
1023
+ if (!refresh.providerViewHash) {
1024
+ await publishSlackAppHomeAccessView(
1025
+ client,
1026
+ refresh,
1027
+ renewLease,
1028
+ buildSlackAppHomeAccessBlocks({
1029
+ title: "Refresh OpenGeni Home",
1030
+ message:
1031
+ "Reopen Home to refresh your tasks safely. OpenGeni does not publish task data without Slack's current view version.",
1032
+ actionLabel: "Open OpenGeni",
1033
+ actionUrl: slackWorkspaceUrl(deps, installation.workspaceId),
1034
+ }),
1035
+ );
1036
+ return;
1037
+ }
1038
+ await renewLease();
1039
+ await client.publishHomeView({
1040
+ userId: refresh.slackUserId,
1041
+ hash: refresh.providerViewHash,
1042
+ blocks: buildSlackAppHomeBlocks({
1043
+ sessions,
1044
+ workspaceUrl: slackWorkspaceUrl(deps, installation.workspaceId),
1045
+ sessionUrl: (sessionId) => slackSessionUrl(deps, installation.workspaceId, sessionId),
1046
+ }),
1047
+ });
1048
+ }
1049
+
1050
+ async function publishSlackAppHomeAccessView(
1051
+ client: OpenGeniSlackBotClient,
1052
+ refresh: SlackAppHomeRefresh,
1053
+ renewLease: () => Promise<void>,
1054
+ blocks: ReturnType<typeof buildSlackAppHomeAccessBlocks>,
1055
+ ): Promise<void> {
1056
+ try {
1057
+ await renewLease();
1058
+ await client.publishHomeView({
1059
+ userId: refresh.slackUserId,
1060
+ hash: refresh.providerViewHash,
1061
+ blocks,
1062
+ });
1063
+ } catch (error) {
1064
+ if (!(error instanceof SlackBotProviderError) || error.code !== "hash_conflict") throw error;
1065
+ // Access views contain no task data. If an older authorized publication
1066
+ // advanced Slack's optimistic-concurrency hash, replace it fail-closed
1067
+ // rather than dropping the newer clearing obligation.
1068
+ await renewLease();
1069
+ await client.publishHomeView({
1070
+ userId: refresh.slackUserId,
1071
+ blocks,
1072
+ });
1073
+ }
1074
+ }
1075
+
786
1076
  export async function drainSlackInteractionsOnce(deps: ApiRouteDeps): Promise<boolean> {
1077
+ const appHomeHolder = crypto.randomUUID();
1078
+ const refresh = await claimSlackAppHomeRefresh(deps.db, appHomeHolder, APP_HOME_LEASE_MS);
1079
+ if (refresh) {
1080
+ try {
1081
+ const installation = await resolveSlackInstallationRoute(deps.db, refresh.slackTeamId);
1082
+ if (
1083
+ !installation ||
1084
+ installation.accountId !== refresh.accountId ||
1085
+ installation.workspaceId !== refresh.workspaceId ||
1086
+ installation.connectionId !== refresh.connectionId
1087
+ ) {
1088
+ throw new SlackInteractionPermanentError("slack_app_home_installation_changed");
1089
+ }
1090
+ const renewLease = async () => {
1091
+ if (
1092
+ !(await renewSlackAppHomeRefreshClaim(deps.db, {
1093
+ refresh,
1094
+ claimHolderId: appHomeHolder,
1095
+ claimLeaseMs: APP_HOME_LEASE_MS,
1096
+ }))
1097
+ ) {
1098
+ throw new SlackInteractionPermanentError("slack_app_home_claim_lost");
1099
+ }
1100
+ };
1101
+ await renewLease();
1102
+ await publishSlackAppHome(deps, installation, refresh, renewLease);
1103
+ await settleSlackAppHomeRefresh(deps.db, {
1104
+ refresh,
1105
+ claimHolderId: appHomeHolder,
1106
+ });
1107
+ } catch (error) {
1108
+ const code = safeErrorCode(error);
1109
+ console.error("[slack-interactions] App Home refresh failed", {
1110
+ workspaceId: refresh.workspaceId,
1111
+ connectionId: refresh.connectionId,
1112
+ slackUserId: refresh.slackUserId,
1113
+ providerEventId: refresh.providerEventId,
1114
+ desiredRevision: refresh.desiredRevision,
1115
+ attemptCount: refresh.attemptCount,
1116
+ errorCode: code,
1117
+ });
1118
+ if (error instanceof SlackBotProviderError && error.code === "hash_conflict") {
1119
+ await settleSlackAppHomeRefresh(deps.db, {
1120
+ refresh,
1121
+ claimHolderId: appHomeHolder,
1122
+ errorCode: code,
1123
+ });
1124
+ } else if (
1125
+ refresh.attemptCount >= 5 ||
1126
+ error instanceof SlackInteractionPermanentError ||
1127
+ permanentSlackDeliveryError(error)
1128
+ ) {
1129
+ await settleSlackAppHomeRefresh(deps.db, {
1130
+ refresh,
1131
+ claimHolderId: appHomeHolder,
1132
+ errorCode: code,
1133
+ });
1134
+ } else {
1135
+ await releaseSlackAppHomeRefresh(deps.db, {
1136
+ refresh,
1137
+ claimHolderId: appHomeHolder,
1138
+ errorCode: code,
1139
+ retryAt: new Date(Date.now() + slackDeliveryRetryMs(error, refresh.attemptCount)),
1140
+ });
1141
+ }
1142
+ }
1143
+ return true;
1144
+ }
1145
+
787
1146
  const holder = crypto.randomUUID();
788
1147
  const entry = await claimSlackInteractionInbox(deps.db, holder, INBOX_LEASE_MS);
789
1148
  if (entry) {
@@ -890,6 +1249,126 @@ export function startSlackInteractionPump(
890
1249
  };
891
1250
  }
892
1251
 
1252
+ /**
1253
+ * A concurrent creator may have won this thread in another workspace between the
1254
+ * tenancy probe and the insert, in which case `getOrCreateSlackInteraction`
1255
+ * adopts that row. The grant, the imported attachments, and the resolved route
1256
+ * all belong to the workspace this pass chose, so none of them may be used
1257
+ * against the adopted row. Retry instead: the next pass sees the thread through
1258
+ * the probe, routes to it, and authorizes it properly.
1259
+ */
1260
+ function requireSlackInteractionMatchesTarget(
1261
+ interaction: Pick<SlackInteraction, "accountId" | "workspaceId">,
1262
+ target: { accountId: string; workspaceId: string },
1263
+ ): void {
1264
+ if (
1265
+ interaction.accountId !== target.accountId ||
1266
+ interaction.workspaceId !== target.workspaceId
1267
+ ) {
1268
+ throw new SlackInteractionRetryableError("slack_route_creation_pending");
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * Gather the durable facts the routing decision needs, then decide.
1274
+ *
1275
+ * With the flag off nothing is read at all: the resolver short-circuits to the
1276
+ * installation's own workspace, so an existing single-workspace install issues
1277
+ * exactly the queries it issued before.
1278
+ */
1279
+ async function resolveSlackRouteForEntry(
1280
+ deps: ApiRouteDeps,
1281
+ home: SlackRouteTenancy,
1282
+ entry: SlackInteractionInboxEntry,
1283
+ subjectId: string,
1284
+ options: {
1285
+ threadTenancy: SlackRouteTenancy | null;
1286
+ askEnabled: boolean;
1287
+ botUserId: string | null;
1288
+ },
1289
+ ): Promise<SlackRouteResolution> {
1290
+ const base = {
1291
+ home,
1292
+ entry,
1293
+ botUserId: options.botUserId,
1294
+ threadTenancy: options.threadTenancy,
1295
+ channelRoute: null,
1296
+ dmRoute: null,
1297
+ personalWorkspaceId: null,
1298
+ candidates: [] as const,
1299
+ routingEnabled: false,
1300
+ askEnabled: false,
1301
+ } as const;
1302
+ // A mapped thread wins outright, and with the flag off nothing is consulted at
1303
+ // all, so neither case reads anything.
1304
+ if (!deps.settings.slackWorkspaceRoutingEnabled || options.threadTenancy) {
1305
+ return resolveSlackWorkspaceRoute(base);
1306
+ }
1307
+ const directMessage = isSlackDirectMessageConversation(entry);
1308
+ const [channelRoute, dmRoute, candidates] = await Promise.all([
1309
+ directMessage
1310
+ ? Promise.resolve(null)
1311
+ : getSlackChannelRoute(deps.db, home, {
1312
+ connectionId: entry.connectionId,
1313
+ slackChannelId: entry.slackChannelId,
1314
+ }),
1315
+ directMessage
1316
+ ? getSlackUserDmRoute(deps.db, home, {
1317
+ connectionId: entry.connectionId,
1318
+ slackUserId: entry.slackUserId,
1319
+ })
1320
+ : Promise.resolve(null),
1321
+ listNamedSubjectSlackRoutableWorkspaces(deps.db, { accountId: home.accountId, subjectId }),
1322
+ ]);
1323
+ return resolveSlackWorkspaceRoute({
1324
+ ...base,
1325
+ channelRoute,
1326
+ dmRoute,
1327
+ candidates,
1328
+ // The candidate set is built from the same derived pointer, so reading it
1329
+ // again would be a second query for an answer already in hand.
1330
+ personalWorkspaceId: candidates.find((candidate) => candidate.personal)?.workspaceId ?? null,
1331
+ routingEnabled: true,
1332
+ askEnabled: options.askEnabled,
1333
+ });
1334
+ }
1335
+
1336
+ /**
1337
+ * Tell the person exactly why nothing started, in their own bot DM.
1338
+ *
1339
+ * Every branch ends with "No session was created." because the one thing a
1340
+ * refusal must never do is leave someone believing work is under way.
1341
+ *
1342
+ * No access-request link is minted here. The existing link flow signs a token
1343
+ * for the installation's own workspace, and offering one for a workspace it
1344
+ * cannot yet resolve would send people to a page that refuses them. Until that
1345
+ * flow accepts a routed workspace, the refusal names the workspace and points
1346
+ * at an administrator.
1347
+ */
1348
+ async function postSlackRouteRefusal(
1349
+ deps: ApiRouteDeps,
1350
+ client: OpenGeniSlackBotClient,
1351
+ entry: SlackInteractionInboxEntry,
1352
+ refusal: Extract<SlackRouteResolution, { kind: "denied" }> & Partial<SlackRouteTenancy>,
1353
+ ): Promise<void> {
1354
+ const available = refusal.candidates.map((candidate) => candidate.label);
1355
+ const text =
1356
+ refusal.reason === "no_access_to_named"
1357
+ ? `OpenGeni does not see a workspace named ${JSON.stringify(refusal.requested ?? "")} that you can start work in.${
1358
+ available.length > 0 ? ` You can use: ${available.join(", ")}.` : ""
1359
+ } No session was created.`
1360
+ : refusal.reason === "no_access_to_route"
1361
+ ? `OpenGeni starts work from this conversation in ${
1362
+ refusal.requested ?? "another workspace"
1363
+ }, and you do not have access to it. Ask an OpenGeni administrator for access to that workspace, or point this conversation somewhere else in OpenGeni under Capabilities, then Slack. No session was created.`
1364
+ : "OpenGeni has no workspace it can start this task in for you. Ask an OpenGeni administrator to give you access to one. No session was created.";
1365
+ await client.postMessage({
1366
+ operationId: deterministicUuid(`slack-route-denied:${entry.id}:${refusal.reason}`),
1367
+ userId: entry.slackUserId,
1368
+ text: boundedOutput(text),
1369
+ });
1370
+ }
1371
+
893
1372
  async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
894
1373
  if (entry.triggerKind === "block_action") {
895
1374
  await processSlackBlockAction(deps, entry);
@@ -899,6 +1378,11 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
899
1378
  await processSlackReactionInboxEntry(deps, entry);
900
1379
  return;
901
1380
  }
1381
+ // HOME is the installation binding's tenancy: it owns the bot credential and
1382
+ // connection, this inbox row, the identity links, and the post ledgers. TARGET
1383
+ // is the tenancy the resulting session lives in. They are equal today; the
1384
+ // routing resolver is what makes them diverge.
1385
+ const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
902
1386
  const installation = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
903
1387
  if (
904
1388
  !installation ||
@@ -909,8 +1393,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
909
1393
  throw new SlackInteractionPermanentError("slack_task_installation_changed");
910
1394
  }
911
1395
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
912
- accountId: entry.accountId,
913
- workspaceId: entry.workspaceId,
1396
+ ...home,
914
1397
  connectionId: entry.connectionId,
915
1398
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
916
1399
  });
@@ -937,17 +1420,21 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
937
1420
  privateHandoff: policyDecision.disposition === "private_handoff",
938
1421
  });
939
1422
  const routeKey = routePolicy.initialRouteKey;
940
- const existing = await getSlackInteractionByRoute(
941
- deps.db,
942
- entry.workspaceId,
943
- entry.connectionId,
1423
+ // A mapped thread keeps the workspace it was created in, so the continuation
1424
+ // lookup is connection-scoped rather than workspace-fenced.
1425
+ const existing = await getSlackInteractionByConnectionRoute(deps.db, {
1426
+ accountId: home.accountId,
1427
+ connectionId: entry.connectionId,
944
1428
  routeKey,
945
- );
1429
+ });
946
1430
  if (entry.triggerKind === "thread_reply" && !existing) return;
947
-
1431
+ // The identity link is a HOME fact: `slack_bot_user_links` is unique on
1432
+ // `(connection_id, slack_user_id)` and RLS-visible only under the
1433
+ // installation's own tenancy. It is read before the target is chosen because
1434
+ // the routing decision needs the subject it names.
948
1435
  const link = await getSlackBotUserLink(
949
1436
  deps.db,
950
- entry.workspaceId,
1437
+ home.workspaceId,
951
1438
  entry.connectionId,
952
1439
  entry.slackUserId,
953
1440
  );
@@ -959,16 +1446,58 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
959
1446
  });
960
1447
  return;
961
1448
  }
962
- const grant = await getWorkspaceGrant(deps.db, link.subjectId, entry.workspaceId, {
963
- principalKind: "human_session",
1449
+ const resolution = await resolveSlackRouteForEntry(deps, home, entry, link.subjectId, {
1450
+ botUserId: installation.botUserId,
1451
+ threadTenancy: existing
1452
+ ? { accountId: existing.accountId, workspaceId: existing.workspaceId }
1453
+ : null,
1454
+ // The first-use picker does not exist yet, so genuine ambiguity keeps the
1455
+ // installation's workspace rather than inventing an answer.
1456
+ askEnabled: false,
1457
+ });
1458
+ if (resolution.kind === "ask") {
1459
+ // Unreachable: `askEnabled` is false above because the first-use picker
1460
+ // lands in a later change. Fail loudly rather than guessing a workspace.
1461
+ throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
1462
+ }
1463
+ if (resolution.kind === "denied") {
1464
+ await postSlackRouteRefusal(deps, client, entry, resolution);
1465
+ return;
1466
+ }
1467
+ const target: { accountId: string; workspaceId: string } = {
1468
+ accountId: resolution.accountId,
1469
+ workspaceId: resolution.workspaceId,
1470
+ };
1471
+ const grant = await resolveSlackTargetAuthority(deps.db, {
1472
+ subjectId: link.subjectId,
1473
+ targetAccountId: target.accountId,
1474
+ targetWorkspaceId: target.workspaceId,
964
1475
  });
965
- if (!grant || grant.accountId !== entry.accountId) {
966
- throw new SlackInteractionPermanentError("identity_access_revoked");
1476
+ if (!grant) {
1477
+ // Never quietly serve this from a workspace the person did not name. A
1478
+ // routed workspace the subject cannot reach is a refusal with the existing
1479
+ // access-request path, minted for the workspace they actually need.
1480
+ if (resolution.source === "installation" || resolution.source === "thread") {
1481
+ throw new SlackInteractionPermanentError("identity_access_revoked");
1482
+ }
1483
+ await postSlackRouteRefusal(deps, client, entry, {
1484
+ kind: "denied",
1485
+ reason: "no_access_to_route",
1486
+ requested: resolution.label,
1487
+ candidates: [],
1488
+ ...target,
1489
+ });
1490
+ return;
967
1491
  }
1492
+ // An override addresses the message; it is not part of the request.
1493
+ const routedEntry: SlackInteractionInboxEntry = {
1494
+ ...entry,
1495
+ text: slackRoutedRequestText(entry.text, resolution, installation.botUserId),
1496
+ };
968
1497
 
969
1498
  const alreadyDurable = await getSlackInteractionByClientEventId(
970
1499
  deps.db,
971
- entry.workspaceId,
1500
+ target.workspaceId,
972
1501
  entry.connectionId,
973
1502
  `slack:${entry.providerEventId}`,
974
1503
  );
@@ -991,14 +1520,13 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
991
1520
  if (!boundInteraction) {
992
1521
  throw new Error("Durable Slack interaction could not bind its reserved session");
993
1522
  }
994
- await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, policyResolution);
1523
+ await ensureSlackSharedTaskOrigin(deps, boundInteraction, entry, home, policyResolution);
995
1524
  const shouldRepairAcknowledgement =
996
1525
  interaction.triggeringProviderEventId === entry.providerEventId ||
997
1526
  (usesPrivateBotDm(boundInteraction, entry) && boundInteraction.ackSlackMessageTs === null);
998
1527
  if (shouldRepairAcknowledgement) {
999
1528
  const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
1000
- accountId: entry.accountId,
1001
- workspaceId: entry.workspaceId,
1529
+ ...home,
1002
1530
  connectionId: entry.connectionId,
1003
1531
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1004
1532
  sessionId: eventSessionId,
@@ -1008,7 +1536,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1008
1536
  return;
1009
1537
  }
1010
1538
 
1011
- let preparedEntry = entry;
1539
+ let preparedEntry = routedEntry;
1540
+ let preparedModelContext: string | null = null;
1012
1541
  let preparedAttachments: PreparedSlackReactionTask = {
1013
1542
  resources: [],
1014
1543
  attachments: [],
@@ -1019,7 +1548,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1019
1548
  const prepared = await prepareSlackInvocationEntry(
1020
1549
  deps,
1021
1550
  client,
1022
- entry,
1551
+ routedEntry,
1552
+ target,
1023
1553
  policyDecision.disposition === "private_handoff"
1024
1554
  ? async () => {
1025
1555
  await requireSlackSharedReadAuthorization(deps, client, entry, policyResolution);
@@ -1027,31 +1557,28 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1027
1557
  : undefined,
1028
1558
  );
1029
1559
  preparedEntry = prepared.entry;
1560
+ preparedModelContext = prepared.modelContext;
1030
1561
  preparedAttachments = prepared.attachments;
1031
1562
  }
1032
1563
 
1033
1564
  if (existing?.sessionId) {
1034
1565
  const remounted = await remountSlackReactionTaskForSession(
1035
1566
  deps,
1036
- entry.workspaceId,
1567
+ target.workspaceId,
1037
1568
  existing.sessionId,
1038
1569
  preparedAttachments,
1039
1570
  );
1040
- await continueSlackSession(
1041
- deps,
1042
- grant,
1043
- existing,
1044
- slackInvocationPreparedEntry(preparedEntry, remounted),
1045
- remounted.resources,
1046
- );
1571
+ const prepared = slackInvocationPreparedMessage(preparedEntry, remounted, preparedModelContext);
1572
+ await continueSlackSession(deps, grant, existing, prepared.entry, remounted.resources, {
1573
+ modelContext: prepared.modelContext,
1574
+ });
1047
1575
  return;
1048
1576
  }
1049
1577
  if (!hasPermission(grant.permissions, "sessions:create")) {
1050
1578
  throw new SlackInteractionPermanentError("sessions_create_denied");
1051
1579
  }
1052
1580
  const { interaction } = await getOrCreateSlackInteraction(deps.db, {
1053
- accountId: entry.accountId,
1054
- workspaceId: entry.workspaceId,
1581
+ ...target,
1055
1582
  connectionId: entry.connectionId,
1056
1583
  slackTeamId: entry.slackTeamId,
1057
1584
  slackChannelId: entry.slackChannelId,
@@ -1062,32 +1589,36 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1062
1589
  owningSubjectId: grant.subjectId,
1063
1590
  visibility: routePolicy.visibility,
1064
1591
  });
1592
+ requireSlackInteractionMatchesTarget(interaction, target);
1065
1593
  if (interaction.sessionId) {
1066
1594
  const remounted = await remountSlackReactionTaskForSession(
1067
1595
  deps,
1068
- entry.workspaceId,
1596
+ interaction.workspaceId,
1069
1597
  interaction.sessionId,
1070
1598
  preparedAttachments,
1071
1599
  );
1072
- await continueSlackSession(
1073
- deps,
1074
- grant,
1075
- interaction,
1076
- slackInvocationPreparedEntry(preparedEntry, remounted),
1077
- remounted.resources,
1078
- );
1600
+ const prepared = slackInvocationPreparedMessage(preparedEntry, remounted, preparedModelContext);
1601
+ await continueSlackSession(deps, grant, interaction, prepared.entry, remounted.resources, {
1602
+ modelContext: prepared.modelContext,
1603
+ });
1079
1604
  return;
1080
1605
  }
1081
1606
  const preferredModel = await getLatestSessionModelForSubject(
1082
1607
  deps.db,
1083
- entry.workspaceId,
1608
+ interaction.workspaceId,
1084
1609
  grant.subjectId,
1085
1610
  );
1086
1611
  let session: Awaited<ReturnType<typeof createSessionForRequest>>;
1087
1612
  try {
1088
- session = await createSessionForRequest(deps, grant, entry.workspaceId, {
1613
+ const prepared = slackInvocationPreparedMessage(
1614
+ preparedEntry,
1615
+ preparedAttachments,
1616
+ preparedModelContext,
1617
+ );
1618
+ session = await createSessionForRequest(deps, grant, interaction.workspaceId, {
1089
1619
  requestedSessionId: interaction.sessionReservationId,
1090
- initialMessage: slackInvocationPreparedEntry(preparedEntry, preparedAttachments).text,
1620
+ initialMessage: prepared.entry.text,
1621
+ ...(prepared.modelContext ? { modelContext: prepared.modelContext } : {}),
1091
1622
  instructions: SLACK_SESSION_INSTRUCTIONS,
1092
1623
  firstPartyMcpTools: slackTaskFirstPartyMcpTools(deps.settings),
1093
1624
  resources: preparedAttachments.resources,
@@ -1118,10 +1649,9 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
1118
1649
  sessionId: session.id,
1119
1650
  });
1120
1651
  if (!bound) throw new Error("Slack route could not bind its durable session");
1121
- await ensureSlackSharedTaskOrigin(deps, bound, entry, policyResolution);
1652
+ await ensureSlackSharedTaskOrigin(deps, bound, entry, home, policyResolution);
1122
1653
  const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
1123
- accountId: entry.accountId,
1124
- workspaceId: entry.workspaceId,
1654
+ ...home,
1125
1655
  connectionId: entry.connectionId,
1126
1656
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1127
1657
  sessionId: session.id,
@@ -1164,6 +1694,7 @@ async function ensureSlackSharedTaskOrigin(
1164
1694
  deps: ApiRouteDeps,
1165
1695
  interaction: SlackInteraction,
1166
1696
  entry: SlackInteractionInboxEntry,
1697
+ home: { accountId: string; workspaceId: string },
1167
1698
  resolution:
1168
1699
  | Awaited<ReturnType<typeof slackTaskPolicyDecision>>
1169
1700
  | {
@@ -1189,6 +1720,11 @@ async function ensureSlackSharedTaskOrigin(
1189
1720
  sourceChannelId: entry.slackChannelId,
1190
1721
  sourceThreadTs: entry.slackThreadTs ?? entry.slackMessageTs,
1191
1722
  initiatingSlackUserId: interaction.initiatingSlackUserId,
1723
+ // The task policy governs what may be read out of the Slack conversation,
1724
+ // which is an installation-surface concern, so its frozen revision keeps
1725
+ // home tenancy while the row itself follows the routed task.
1726
+ policyAccountId: home.accountId,
1727
+ policyWorkspaceId: home.workspaceId,
1192
1728
  policyRevisionId: resolution.activePolicy.revision.id,
1193
1729
  policyHash: resolution.activePolicy.revision.policyHash,
1194
1730
  policyActivationVersion: resolution.activePolicy.head.activationVersion,
@@ -1225,8 +1761,15 @@ async function prepareSlackInvocationEntry(
1225
1761
  deps: ApiRouteDeps,
1226
1762
  client: OpenGeniSlackBotClient,
1227
1763
  entry: SlackInteractionInboxEntry,
1764
+ // Imported attachments become File resources of the session's workspace, so
1765
+ // they follow TARGET tenancy rather than the installation's.
1766
+ target: { accountId: string; workspaceId: string },
1228
1767
  authorizeRead?: () => Promise<void>,
1229
- ): Promise<{ entry: SlackInteractionInboxEntry; attachments: PreparedSlackReactionTask }> {
1768
+ ): Promise<{
1769
+ entry: SlackInteractionInboxEntry;
1770
+ attachments: PreparedSlackReactionTask;
1771
+ modelContext: string | null;
1772
+ }> {
1230
1773
  const context = entry.slackThreadTs
1231
1774
  ? await client.threadReplies({
1232
1775
  channelId: entry.slackChannelId,
@@ -1255,53 +1798,58 @@ async function prepareSlackInvocationEntry(
1255
1798
  exactMessage = exact.messages.find((message) => message.timestamp === entry.slackMessageTs);
1256
1799
  }
1257
1800
  const attachments = exactMessage
1258
- ? await prepareSlackMessageAttachments(deps, client, entry, exactMessage.files)
1801
+ ? await prepareSlackMessageAttachments(
1802
+ deps,
1803
+ client,
1804
+ entry,
1805
+ target,
1806
+ exactMessage.files,
1807
+ authorizeRead,
1808
+ )
1259
1809
  : {
1260
1810
  resources: [],
1261
1811
  attachments: [],
1262
1812
  omissionCodes: entry.hasFiles ? ["attachment_unavailable"] : [],
1263
1813
  omittedCount: entry.hasFiles ? 1 : 0,
1264
1814
  };
1265
- const baseText =
1815
+ const modelContext =
1266
1816
  entry.triggerKind === "app_mention"
1267
- ? slackInvocationTaskText(entry, {
1817
+ ? slackInvocationModelContext(entry.slackMessageTs, {
1268
1818
  messages: context.messages,
1269
1819
  nextCursor: context.nextCursor,
1270
1820
  kind: entry.slackThreadTs ? "thread" : "channel",
1271
1821
  })
1272
- : entry.text;
1822
+ : null;
1273
1823
  return {
1274
- entry: { ...entry, text: baseText },
1824
+ entry,
1275
1825
  attachments,
1826
+ modelContext,
1276
1827
  };
1277
1828
  }
1278
1829
 
1279
- function slackInvocationPreparedEntry(
1830
+ function slackInvocationPreparedMessage(
1280
1831
  entry: SlackInteractionInboxEntry,
1281
1832
  attachments: PreparedSlackReactionTask,
1282
- ): SlackInteractionInboxEntry {
1833
+ modelContext: string | null,
1834
+ ): { entry: SlackInteractionInboxEntry; modelContext: string | null } {
1283
1835
  const manifest = slackAttachmentManifest(
1284
1836
  attachments,
1285
1837
  "Imported invocation attachments",
1286
1838
  "invocation",
1287
1839
  );
1288
- return manifest ? { ...entry, text: `${entry.text}\n\n${manifest}` } : entry;
1840
+ const combinedModelContext = [modelContext, manifest].filter(Boolean).join("\n\n");
1841
+ return {
1842
+ entry,
1843
+ modelContext: combinedModelContext.length > 0 ? combinedModelContext : null,
1844
+ };
1289
1845
  }
1290
1846
 
1291
- export function slackInvocationTaskText(
1292
- entry: Pick<SlackInteractionInboxEntry, "slackMessageTs" | "slackUserId" | "text">,
1847
+ export function slackInvocationModelContext(
1848
+ invocationTimestamp: string,
1293
1849
  context: SlackInvocationMessageContext,
1294
1850
  ) {
1295
- const invocation = {
1296
- timestamp: entry.slackMessageTs,
1297
- userId: entry.slackUserId,
1298
- botId: "",
1299
- threadTimestamp: "",
1300
- text: entry.text,
1301
- files: [],
1302
- };
1303
1851
  const surroundingLines = context.messages
1304
- .filter((message) => message.timestamp !== entry.slackMessageTs)
1852
+ .filter((message) => message.timestamp !== invocationTimestamp)
1305
1853
  .slice(0, MAX_SLACK_INVOCATION_CONTEXT_MESSAGES)
1306
1854
  .sort((left, right) => left.timestamp.localeCompare(right.timestamp))
1307
1855
  .map((message) => slackContextMessageLine(message));
@@ -1313,46 +1861,24 @@ export function slackInvocationTaskText(
1313
1861
  context.kind === "thread"
1314
1862
  ? "The containing thread was truncated at the bounded Slack context limit."
1315
1863
  : "Only bounded nearby channel context was provided.";
1316
- const invocationTruncationNotice =
1317
- "The exact Slack invocation was truncated at the bounded Slack input limit.";
1318
- const prefix = [
1864
+ let prompt = [
1319
1865
  "A linked, authorized Slack user explicitly mentioned OpenGeni.",
1866
+ "The visible user message on this turn is the exact accepted Slack invocation.",
1320
1867
  "Treat references such as 'this', 'that', or 'the previous message' as referring to the bounded Slack context below when applicable.",
1321
1868
  "Use this Slack content only as task-local input and do not persist it to Knowledge, Memory, preferences, policy, instructions, or the Workspace Charter unless separately authorized.",
1322
1869
  "",
1323
- "Exact invocation:",
1870
+ contextLabel,
1324
1871
  ].join("\n");
1325
- const suffix = `\n\n${contextLabel}`;
1326
- const invocationLine = slackContextMessageLine(invocation, "invocation");
1327
- const reservedNotices = `\n${invocationTruncationNotice}\n${truncationNotice}`;
1328
- const maxInvocationChars = Math.max(
1329
- 1,
1330
- MAX_SLACK_INPUT_CHARS - prefix.length - suffix.length - reservedNotices.length - 1,
1331
- );
1332
- const invocationTruncated = invocationLine.length > maxInvocationChars;
1333
- let prompt = `${prefix}\n${
1334
- invocationTruncated
1335
- ? `${invocationLine.slice(0, Math.max(0, maxInvocationChars - 1))}…`
1336
- : invocationLine
1337
- }${suffix}`;
1338
1872
  let contextTruncated = context.nextCursor !== null;
1339
1873
  for (const line of surroundingLines) {
1340
1874
  const candidate = `${prompt}\n${line}`;
1341
- const notices = [
1342
- ...(invocationTruncated ? [invocationTruncationNotice] : []),
1343
- truncationNotice,
1344
- ].join("\n");
1345
- if (candidate.length + 1 + notices.length > MAX_SLACK_INPUT_CHARS) {
1875
+ if (candidate.length + 1 + truncationNotice.length > MAX_SLACK_INPUT_CHARS) {
1346
1876
  contextTruncated = true;
1347
1877
  break;
1348
1878
  }
1349
1879
  prompt = candidate;
1350
1880
  }
1351
- const notices = [
1352
- ...(invocationTruncated ? [invocationTruncationNotice] : []),
1353
- ...(contextTruncated ? [truncationNotice] : []),
1354
- ];
1355
- return notices.length > 0 ? `${prompt}\n${notices.join("\n")}` : prompt;
1881
+ return contextTruncated ? `${prompt}\n${truncationNotice}` : prompt;
1356
1882
  }
1357
1883
 
1358
1884
  async function acknowledgeSlackSession(
@@ -1369,11 +1895,28 @@ async function acknowledgeSlackSession(
1369
1895
  !directMessageShortcut && interaction.visibility === "private" && entry.triggerKind !== "dm";
1370
1896
  const privateBotDm = directMessageShortcut || privateHandoff;
1371
1897
  const operationId = deterministicUuid(`slack-ack:${interaction.id}`);
1372
- const text = directMessageShortcut
1373
- ? `OpenGeni started a private task from the selected DM message. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. The source DM was not opened to the bot or made workspace-visible.`
1898
+ // The acknowledgement carries exactly one session link plus the Status/Stop
1899
+ // buttons. The how-to prose it used to repeat forever now rides along once,
1900
+ // on this Slack identity's first accepted task in this installation.
1901
+ const started = directMessageShortcut
1902
+ ? `OpenGeni started a private task from the selected DM message. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} The source DM was not opened to the bot or made workspace-visible.`
1374
1903
  : privateHandoff
1375
- ? `OpenGeni started a private task from the selected Slack conversation. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. Results stay private unless a separate authorized publication is approved.`
1376
- : `OpenGeni started this task. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this thread to continue, or reply \`stop\` to stop. Start a new top-level DM or invoke /opengeni again for a new session.`;
1904
+ ? `OpenGeni started a private task from the selected Slack conversation. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)} Results stay private unless a separate authorized publication is approved.`
1905
+ : `OpenGeni started this task. ${openSessionText(deps, interaction.workspaceId, interaction.sessionId)}`;
1906
+ // The post ledger binds this fixed operation id to a digest over the message
1907
+ // text, and this function re-runs on every acknowledgement repair. Resolving
1908
+ // the hint freezes it durably before the post, so a repair renders identical
1909
+ // bytes; a failure here raises into the retryable inbox path rather than
1910
+ // binding the ledger to a hint-less acknowledgement it can never revise.
1911
+ const showHint = await resolveSlackInteractionFirstTaskHint(deps.db, {
1912
+ // The identity link is HOME; the interaction row is TARGET.
1913
+ workspaceId: entry.workspaceId,
1914
+ interactionWorkspaceId: interaction.workspaceId,
1915
+ connectionId: entry.connectionId,
1916
+ slackUserId: entry.slackUserId,
1917
+ interactionId: interaction.id,
1918
+ });
1919
+ const text = `${started}${showHint ? slackFirstTaskHintText(deps) : ""}`;
1377
1920
  const controls = await controlActionBlocks(deps, interaction, {
1378
1921
  messageOperationId: operationId,
1379
1922
  sessionEventSequence: 0,
@@ -1412,14 +1955,38 @@ async function acknowledgeSlackSession(
1412
1955
  }
1413
1956
  }
1414
1957
 
1958
+ /**
1959
+ * The one-time onboarding sentence shown on a Slack identity's first
1960
+ * acknowledged task in one installation.
1961
+ *
1962
+ * Rendering is a pure function of the configured command plus the frozen
1963
+ * `firstTaskHint` fact, so every re-render of the same acknowledgement produces
1964
+ * the same bytes for the digest-bound post ledger.
1965
+ *
1966
+ * Note the one remaining input that is not frozen: the text embeds
1967
+ * `settings.slackCommand`. Changing that setting between an acknowledgement
1968
+ * post and a later repair of the same interaction would diverge the digest and
1969
+ * conflict its post operation. The command is deployment configuration that
1970
+ * must already match the registered Slack slash command, so it does not change
1971
+ * under a live installation; a deployment that does rename it should drain
1972
+ * in-flight Slack interactions first.
1973
+ */
1974
+ function slackFirstTaskHintText(deps: ApiRouteDeps): string {
1975
+ const command = deps.settings.slackCommand;
1976
+ return `\n\nFirst time here: reply in this thread to continue this task, or reply \`stop\` to stop it. Start a new top-level DM or run \`${command}\` again for a new task. Run \`${command} info\` any time for the full summary.`;
1977
+ }
1978
+
1415
1979
  async function processSlackReactionInboxEntry(
1416
1980
  deps: ApiRouteDeps,
1417
1981
  entry: SlackInteractionInboxEntry,
1418
1982
  ) {
1983
+ // Reaction summon is configured on the installation surface, so the settings,
1984
+ // the connection scopes, and the identity link are all HOME reads.
1985
+ const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
1419
1986
  const [workspace, connection, link] = await Promise.all([
1420
- getWorkspace(deps.db, entry.workspaceId),
1421
- getConnectionMetadata(deps.db, entry.workspaceId, entry.connectionId, null),
1422
- getSlackBotUserLink(deps.db, entry.workspaceId, entry.connectionId, entry.slackUserId),
1987
+ getWorkspace(deps.db, home.workspaceId),
1988
+ getConnectionMetadata(deps.db, home.workspaceId, entry.connectionId, null),
1989
+ getSlackBotUserLink(deps.db, home.workspaceId, entry.connectionId, entry.slackUserId),
1423
1990
  ]);
1424
1991
  const settings = resolveWorkspaceSlackReactionSummonSettings(workspace?.settings);
1425
1992
  if (
@@ -1433,23 +2000,55 @@ async function processSlackReactionInboxEntry(
1433
2000
  ) {
1434
2001
  return;
1435
2002
  }
1436
- const grant = await getWorkspaceGrant(deps.db, link.subjectId, entry.workspaceId, {
1437
- principalKind: "human_session",
2003
+ // A reaction is never a direct message, so the routing decision here is the
2004
+ // channel's remembered answer, the sole candidate, or the installation's own
2005
+ // workspace. It runs before the provider context read so an unauthorized
2006
+ // subject never causes OpenGeni to read the Slack conversation.
2007
+ const routeResolution = await resolveSlackRouteForEntry(deps, home, entry, link.subjectId, {
2008
+ // A reaction carries no message text of the reacting person, so no prefix.
2009
+ botUserId: null,
2010
+ threadTenancy: null,
2011
+ askEnabled: false,
1438
2012
  });
1439
- if (!grant || grant.accountId !== entry.accountId) {
1440
- throw new SlackInteractionPermanentError("identity_access_revoked");
2013
+ if (routeResolution.kind === "ask") {
2014
+ throw new SlackInteractionPermanentError("slack_route_choice_unavailable");
1441
2015
  }
1442
- if (
1443
- !hasPermission(grant.permissions, "sessions:create") ||
1444
- !hasPermission(grant.permissions, "sessions:control")
1445
- ) {
1446
- throw new SlackInteractionPermanentError("reaction_session_permissions_denied");
2016
+ if (routeResolution.kind === "denied") {
2017
+ const summonClient = await createOpenGeniSlackBotInteractionClient(deps, {
2018
+ ...home,
2019
+ connectionId: entry.connectionId,
2020
+ subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
2021
+ });
2022
+ await postSlackRouteRefusal(deps, summonClient, entry, routeResolution);
2023
+ return;
1447
2024
  }
2025
+ let target: { accountId: string; workspaceId: string } = {
2026
+ accountId: routeResolution.accountId,
2027
+ workspaceId: routeResolution.workspaceId,
2028
+ };
2029
+ const authorizeTarget = async () => {
2030
+ const resolved = await resolveSlackTargetAuthority(deps.db, {
2031
+ subjectId: link.subjectId,
2032
+ targetAccountId: target.accountId,
2033
+ targetWorkspaceId: target.workspaceId,
2034
+ });
2035
+ if (!resolved) {
2036
+ throw new SlackInteractionPermanentError("identity_access_revoked");
2037
+ }
2038
+ if (
2039
+ !hasPermission(resolved.permissions, "sessions:create") ||
2040
+ !hasPermission(resolved.permissions, "sessions:control")
2041
+ ) {
2042
+ throw new SlackInteractionPermanentError("reaction_session_permissions_denied");
2043
+ }
2044
+ return resolved;
2045
+ };
2046
+ let grant = await authorizeTarget();
1448
2047
 
1449
2048
  const clientEventId = `slack:${entry.providerEventId}`;
1450
2049
  const durableInteraction = await getSlackInteractionByClientEventId(
1451
2050
  deps.db,
1452
- entry.workspaceId,
2051
+ target.workspaceId,
1453
2052
  entry.connectionId,
1454
2053
  clientEventId,
1455
2054
  );
@@ -1481,8 +2080,7 @@ async function processSlackReactionInboxEntry(
1481
2080
  await reopenSlackInteractionDelivery(deps.db, boundInteraction);
1482
2081
  if (shouldRepairAcknowledgement) {
1483
2082
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
1484
- accountId: entry.accountId,
1485
- workspaceId: entry.workspaceId,
2083
+ ...home,
1486
2084
  connectionId: entry.connectionId,
1487
2085
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1488
2086
  sessionId: eventSessionId,
@@ -1493,8 +2091,7 @@ async function processSlackReactionInboxEntry(
1493
2091
  }
1494
2092
 
1495
2093
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
1496
- accountId: entry.accountId,
1497
- workspaceId: entry.workspaceId,
2094
+ ...home,
1498
2095
  connectionId: entry.connectionId,
1499
2096
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
1500
2097
  });
@@ -1525,18 +2122,25 @@ async function processSlackReactionInboxEntry(
1525
2122
  if (!saved) throw new Error("Slack reaction inbox checkpoint claim was lost");
1526
2123
  },
1527
2124
  });
1528
- const preparedTask = await prepareSlackReactionTask(deps, client, entry, context);
1529
2125
  const routeKey = slackRouteKey(entry.slackChannelId, context.threadTimestamp);
1530
- const existing = await getSlackInteractionByRoute(
1531
- deps.db,
1532
- entry.workspaceId,
1533
- entry.connectionId,
2126
+ const existing = await getSlackInteractionByConnectionRoute(deps.db, {
2127
+ accountId: home.accountId,
2128
+ connectionId: entry.connectionId,
1534
2129
  routeKey,
1535
- );
2130
+ });
2131
+ if (
2132
+ existing &&
2133
+ (existing.accountId !== target.accountId || existing.workspaceId !== target.workspaceId)
2134
+ ) {
2135
+ // A mapped thread keeps the workspace it was created in.
2136
+ target = { accountId: existing.accountId, workspaceId: existing.workspaceId };
2137
+ grant = await authorizeTarget();
2138
+ }
2139
+ const preparedTask = await prepareSlackReactionTask(deps, client, entry, target, context);
1536
2140
  if (existing?.sessionId) {
1537
2141
  const appendedTask = await remountSlackReactionTaskForSession(
1538
2142
  deps,
1539
- entry.workspaceId,
2143
+ existing.workspaceId,
1540
2144
  existing.sessionId,
1541
2145
  preparedTask,
1542
2146
  );
@@ -1550,8 +2154,7 @@ async function processSlackReactionInboxEntry(
1550
2154
  return;
1551
2155
  }
1552
2156
  const { interaction } = await getOrCreateSlackInteraction(deps.db, {
1553
- accountId: entry.accountId,
1554
- workspaceId: entry.workspaceId,
2157
+ ...target,
1555
2158
  connectionId: entry.connectionId,
1556
2159
  slackTeamId: entry.slackTeamId,
1557
2160
  slackChannelId: entry.slackChannelId,
@@ -1562,10 +2165,11 @@ async function processSlackReactionInboxEntry(
1562
2165
  owningSubjectId: grant.subjectId,
1563
2166
  visibility: "workspace",
1564
2167
  });
2168
+ requireSlackInteractionMatchesTarget(interaction, target);
1565
2169
  if (interaction.sessionId) {
1566
2170
  const appendedTask = await remountSlackReactionTaskForSession(
1567
2171
  deps,
1568
- entry.workspaceId,
2172
+ interaction.workspaceId,
1569
2173
  interaction.sessionId,
1570
2174
  preparedTask,
1571
2175
  );
@@ -1588,13 +2192,13 @@ async function processSlackReactionInboxEntry(
1588
2192
  }
1589
2193
  const preferredModel = await getLatestSessionModelForSubject(
1590
2194
  deps.db,
1591
- entry.workspaceId,
2195
+ interaction.workspaceId,
1592
2196
  grant.subjectId,
1593
2197
  );
1594
2198
  const preparedEntry = slackReactionPreparedEntry(entry, context, preparedTask);
1595
2199
  let session: Awaited<ReturnType<typeof createSessionForRequest>>;
1596
2200
  try {
1597
- session = await createSessionForRequest(deps, grant, entry.workspaceId, {
2201
+ session = await createSessionForRequest(deps, grant, interaction.workspaceId, {
1598
2202
  requestedSessionId: interaction.sessionReservationId,
1599
2203
  initialMessage: preparedEntry.text,
1600
2204
  instructions: SLACK_SESSION_INSTRUCTIONS,
@@ -1761,16 +2365,25 @@ async function prepareSlackReactionTask(
1761
2365
  deps: ApiRouteDeps,
1762
2366
  client: OpenGeniSlackBotClient,
1763
2367
  entry: SlackInteractionInboxEntry,
2368
+ target: { accountId: string; workspaceId: string },
1764
2369
  context: SlackReactionMessageContext,
1765
2370
  ): Promise<PreparedSlackReactionTask> {
1766
- return await prepareSlackMessageAttachments(deps, client, entry, context.reactedMessage.files);
2371
+ return await prepareSlackMessageAttachments(
2372
+ deps,
2373
+ client,
2374
+ entry,
2375
+ target,
2376
+ context.reactedMessage.files,
2377
+ );
1767
2378
  }
1768
2379
 
1769
2380
  async function prepareSlackMessageAttachments(
1770
2381
  deps: ApiRouteDeps,
1771
2382
  client: OpenGeniSlackBotClient,
1772
2383
  entry: SlackInteractionInboxEntry,
2384
+ target: { accountId: string; workspaceId: string },
1773
2385
  exactFiles: readonly { id: string; name: string; title: string }[],
2386
+ authorizeSharedRead?: () => Promise<void>,
1774
2387
  ): Promise<PreparedSlackReactionTask> {
1775
2388
  if (exactFiles.length === 0) {
1776
2389
  return { resources: [], attachments: [], omissionCodes: [], omittedCount: 0 };
@@ -1790,12 +2403,13 @@ async function prepareSlackMessageAttachments(
1790
2403
  name: file.name,
1791
2404
  title: file.title,
1792
2405
  })),
2406
+ ...(authorizeSharedRead ? { authorizeSharedRead } : {}),
1793
2407
  });
1794
2408
  const downloaded: Awaited<ReturnType<OpenGeniSlackBotClient["downloadReactionImage"]>>[] = [];
1795
2409
  let aggregateBytes = 0;
1796
2410
  for (const image of prepared) {
1797
2411
  try {
1798
- const value = await client.downloadReactionImage(image);
2412
+ const value = await client.downloadReactionImage(image, authorizeSharedRead);
1799
2413
  if (
1800
2414
  value.bytes.byteLength > SLACK_REACTION_IMAGE_MAX_BYTES ||
1801
2415
  aggregateBytes + value.bytes.byteLength > MAX_SLACK_REACTION_IMAGE_AGGREGATE_BYTES
@@ -1828,8 +2442,8 @@ async function prepareSlackMessageAttachments(
1828
2442
  await importSlackReactionImage(
1829
2443
  { db: deps.db, objectStorage: deps.objectStorage },
1830
2444
  {
1831
- accountId: entry.accountId,
1832
- workspaceId: entry.workspaceId,
2445
+ accountId: target.accountId,
2446
+ workspaceId: target.workspaceId,
1833
2447
  connectionId: entry.connectionId,
1834
2448
  slackTeamId: entry.slackTeamId,
1835
2449
  slackChannelId: entry.slackChannelId,
@@ -1968,9 +2582,11 @@ async function acceptSlackReactionTask(
1968
2582
  resources: FileResourceRef[],
1969
2583
  ) {
1970
2584
  const clientEventId = `slack:${entry.providerEventId}`;
2585
+ // Session events and user messages belong to the workspace that owns the
2586
+ // session, which is the workspace the grant authorized, not the installation.
1971
2587
  const existing = await getSessionEventByClientEventId(
1972
2588
  deps.db,
1973
- entry.workspaceId,
2589
+ grant.workspaceId,
1974
2590
  sessionId,
1975
2591
  clientEventId,
1976
2592
  );
@@ -1980,7 +2596,7 @@ async function acceptSlackReactionTask(
1980
2596
  }
1981
2597
  return;
1982
2598
  }
1983
- await acceptSessionUserMessage(deps, grant, entry.workspaceId, sessionId, {
2599
+ await acceptSessionUserMessage(deps, grant, grant.workspaceId, sessionId, {
1984
2600
  text: entry.text,
1985
2601
  resources,
1986
2602
  clientEventId,
@@ -1993,6 +2609,7 @@ async function continueSlackSession(
1993
2609
  interaction: SlackInteraction,
1994
2610
  entry: SlackInteractionInboxEntry,
1995
2611
  resources: FileResourceRef[] = [],
2612
+ options: { modelContext?: string | null } = {},
1996
2613
  ) {
1997
2614
  if (
1998
2615
  !interaction.sessionId ||
@@ -2023,7 +2640,7 @@ async function continueSlackSession(
2023
2640
  }
2024
2641
  const pending = await listSessionHumanInputRequests(
2025
2642
  deps.db,
2026
- entry.workspaceId,
2643
+ interaction.workspaceId,
2027
2644
  interaction.sessionId,
2028
2645
  { status: "pending", limit: 2 },
2029
2646
  );
@@ -2063,8 +2680,9 @@ async function continueSlackSession(
2063
2680
  if (!hasPermission(grant.permissions, "sessions:control")) {
2064
2681
  throw new SlackInteractionPermanentError("sessions_control_denied");
2065
2682
  }
2066
- await acceptSessionUserMessage(deps, grant, entry.workspaceId, interaction.sessionId, {
2683
+ await acceptSessionUserMessage(deps, grant, interaction.workspaceId, interaction.sessionId, {
2067
2684
  text: entry.text,
2685
+ ...(options.modelContext ? { modelContext: options.modelContext } : {}),
2068
2686
  resources,
2069
2687
  clientEventId: `slack:${entry.providerEventId}`,
2070
2688
  });
@@ -2085,6 +2703,9 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
2085
2703
  const separator = entry.text.lastIndexOf(":");
2086
2704
  const actionId = separator > 0 ? entry.text.slice(0, separator) : "";
2087
2705
  const handleId = separator > 0 ? entry.text.slice(separator + 1) : "";
2706
+ // The block-action inbox row carries HOME tenancy, exactly like every other
2707
+ // inbox row: it is the installation binding that received the click.
2708
+ const home = { accountId: entry.accountId, workspaceId: entry.workspaceId } as const;
2088
2709
  const route = await resolveSlackInstallationRoute(deps.db, entry.slackTeamId);
2089
2710
  if (
2090
2711
  !route ||
@@ -2094,19 +2715,32 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
2094
2715
  ) {
2095
2716
  throw new SlackInteractionPermanentError("slack_action_installation_changed");
2096
2717
  }
2718
+ // A handle lives in the workspace that owns its session, which is not the
2719
+ // installation's workspace once routing is on. Learn that scope from the
2720
+ // content-free probe, then read the full handle under it: every authorization
2721
+ // check below is unchanged and still runs in the handle's own tenancy.
2722
+ const handleTenancy =
2723
+ (await probeSlackActionHandleTenancy(deps.db, {
2724
+ accountId: home.accountId,
2725
+ connectionId: entry.connectionId,
2726
+ handleId,
2727
+ })) ?? home;
2097
2728
  const handle = await getSlackInteractionActionHandle(deps.db, {
2098
- accountId: entry.accountId,
2099
- workspaceId: entry.workspaceId,
2729
+ ...handleTenancy,
2100
2730
  handleId,
2101
2731
  });
2102
2732
  if (!handle || SLACK_ACTION_ID_BY_KIND[handle.actionKind] !== actionId) {
2103
2733
  throw new SlackInteractionPermanentError("slack_action_handle_invalid");
2104
2734
  }
2735
+ // An action handle is a TARGET fact: it is composite-FK'd to its interaction
2736
+ // and its RESTRICTIVE session-visibility policy resolves the session under the
2737
+ // handle's own tenancy, so every read, settle and interaction lookup below
2738
+ // uses the handle's scope rather than the installation's.
2739
+ const target = { accountId: handle.accountId, workspaceId: handle.workspaceId } as const;
2105
2740
  if (handle.status !== "pending") return;
2106
2741
  if (handle.expiresAt.getTime() <= Date.now()) {
2107
2742
  await settleSlackInteractionActionHandles(deps.db, {
2108
- accountId: entry.accountId,
2109
- workspaceId: entry.workspaceId,
2743
+ ...target,
2110
2744
  handleId: handle.id,
2111
2745
  result: "expired",
2112
2746
  stale: true,
@@ -2118,14 +2752,15 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
2118
2752
  }
2119
2753
  const [interaction, link, post] = await Promise.all([
2120
2754
  getSlackInteractionById(deps.db, {
2121
- accountId: entry.accountId,
2122
- workspaceId: entry.workspaceId,
2755
+ ...target,
2123
2756
  interactionId: handle.interactionId,
2124
2757
  }),
2125
- getSlackBotUserLink(deps.db, entry.workspaceId, entry.connectionId, entry.slackUserId),
2758
+ // Identity stays HOME.
2759
+ getSlackBotUserLink(deps.db, home.workspaceId, entry.connectionId, entry.slackUserId),
2760
+ // The post ledger is written by the bot credential, so it stays HOME too.
2126
2761
  getSlackBotPostOperation(
2127
2762
  deps.db,
2128
- entry.workspaceId,
2763
+ home.workspaceId,
2129
2764
  entry.connectionId,
2130
2765
  handle.messageOperationId,
2131
2766
  ),
@@ -2146,34 +2781,42 @@ async function processSlackBlockAction(deps: ApiRouteDeps, entry: SlackInteracti
2146
2781
  ) {
2147
2782
  throw new SlackInteractionPermanentError("slack_action_authority_changed");
2148
2783
  }
2149
- const grant = await getWorkspaceGrant(deps.db, link.subjectId, entry.workspaceId, {
2150
- principalKind: "human_session",
2784
+ const grant = await resolveSlackTargetAuthority(deps.db, {
2785
+ subjectId: link.subjectId,
2786
+ targetAccountId: target.accountId,
2787
+ targetWorkspaceId: target.workspaceId,
2151
2788
  });
2152
- if (
2153
- !grant ||
2154
- grant.accountId !== entry.accountId ||
2155
- !hasPermission(grant.permissions, "sessions:control")
2156
- ) {
2789
+ if (!grant || !hasPermission(grant.permissions, "sessions:control")) {
2157
2790
  throw new SlackInteractionPermanentError("sessions_control_denied");
2158
2791
  }
2159
2792
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
2160
- accountId: entry.accountId,
2161
- workspaceId: entry.workspaceId,
2793
+ ...home,
2162
2794
  connectionId: entry.connectionId,
2163
2795
  subjectId: grant.subjectId,
2164
2796
  sessionId: handle.sessionId,
2165
2797
  });
2166
2798
  const outcome = await executeSlackAction(deps, grant, interaction, handle);
2799
+ // A control click replaces the message it was pressed on. When that message
2800
+ // is this interaction's acknowledgement and this interaction won the one-time
2801
+ // onboarding hint, the update carries the hint forward: otherwise pressing
2802
+ // Status would permanently destroy the only copy of prose a Slack identity is
2803
+ // ever shown. The handle's message operation identifies the acknowledgement
2804
+ // exactly, independent of route rekeying. Later control cards have their own
2805
+ // operation ids, so the hint still appears on exactly one message.
2806
+ const acknowledgementOperationId = deterministicUuid(`slack-ack:${interaction.id}`);
2807
+ const updateText =
2808
+ interaction.firstTaskHint === true && handle.messageOperationId === acknowledgementOperationId
2809
+ ? `${outcome.text}${slackFirstTaskHintText(deps)}`
2810
+ : outcome.text;
2167
2811
  await client.updateMessage({
2168
2812
  operationId: deterministicUuid(`slack-action-update:${handle.id}:${outcome.result}`),
2169
2813
  channelId: entry.slackChannelId,
2170
2814
  timestamp: entry.slackMessageTs,
2171
- text: outcome.text,
2172
- blocks: [{ type: "section", text: { type: "mrkdwn", text: outcome.text } }],
2815
+ text: updateText,
2816
+ blocks: [{ type: "section", text: { type: "mrkdwn", text: updateText } }],
2173
2817
  });
2174
2818
  await settleSlackInteractionActionHandles(deps.db, {
2175
- accountId: entry.accountId,
2176
- workspaceId: entry.workspaceId,
2819
+ ...target,
2177
2820
  handleId: handle.id,
2178
2821
  result: outcome.result,
2179
2822
  ...(outcome.stale ? { stale: true } : {}),
@@ -2348,9 +2991,20 @@ async function executeSlackAction(
2348
2991
  }
2349
2992
  const session = await getSession(deps.db, grant.workspaceId, handle.sessionId);
2350
2993
  if (!session) throw new SlackInteractionPermanentError("slack_action_session_missing");
2994
+ // The result post no longer advertises recurrence. The Status card is where
2995
+ // the requester finds it, and only while this exact requester still holds
2996
+ // schedule authority in this workspace. The deep link and the schedules
2997
+ // authority behind it are unchanged: the browser rechecks both.
2998
+ const recurring =
2999
+ hasPermission(grant.permissions, "sessions:read") &&
3000
+ hasPermission(grant.permissions, "scheduled_tasks:manage")
3001
+ ? makeRecurringText(deps, grant.workspaceId, handle.sessionId)
3002
+ : null;
2351
3003
  return {
2352
3004
  result: "status",
2353
- text: `${mention}OpenGeni task status: *${session.status.replaceAll("_", " ")}*.`,
3005
+ text: `${mention}OpenGeni task status: *${session.status.replaceAll("_", " ")}*.${
3006
+ recurring ? `\n\n${recurring}` : ""
3007
+ }`,
2354
3008
  ...(session.status === "cancelled" || session.status === "failed"
2355
3009
  ? {}
2356
3010
  : { controlState: "active" as const }),
@@ -2367,18 +3021,25 @@ async function publishSlackSharedResult(
2367
3021
  if (!handle.targetId || !interaction.sessionId) {
2368
3022
  throw new SlackInteractionPermanentError("slack_shared_publication_target_invalid");
2369
3023
  }
2370
- const [origin, activePolicy, event] = await Promise.all([
3024
+ const publicationHome = await resolveSlackDeliveryHome(deps, interaction);
3025
+ const [origin, event] = await Promise.all([
2371
3026
  getSlackSharedTaskOrigin(deps.db, {
2372
3027
  accountId: interaction.accountId,
2373
3028
  workspaceId: interaction.workspaceId,
2374
3029
  interactionId: interaction.id,
2375
3030
  }),
2376
- getActiveSlackTaskPolicy(deps.db, {
2377
- accountId: interaction.accountId,
2378
- workspaceId: interaction.workspaceId,
2379
- }),
2380
3031
  getSessionEvent(deps.db, interaction.workspaceId, handle.targetId),
2381
3032
  ]);
3033
+ // The Slack task policy governs what may be read out of, and published back
3034
+ // to, the Slack conversation, so it is an installation-surface fact. The
3035
+ // origin froze which tenancy that was; a row written before routing existed
3036
+ // carries null and implied its own, because the two could not differ then.
3037
+ const activePolicy = origin
3038
+ ? await getActiveSlackTaskPolicy(deps.db, {
3039
+ accountId: origin.policyAccountId ?? origin.accountId,
3040
+ workspaceId: origin.policyWorkspaceId ?? origin.workspaceId,
3041
+ })
3042
+ : null;
2382
3043
  if (
2383
3044
  !origin ||
2384
3045
  origin.connectionId !== interaction.connectionId ||
@@ -2400,8 +3061,7 @@ async function publishSlackSharedResult(
2400
3061
  };
2401
3062
  }
2402
3063
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
2403
- accountId: interaction.accountId,
2404
- workspaceId: interaction.workspaceId,
3064
+ ...publicationHome,
2405
3065
  connectionId: interaction.connectionId,
2406
3066
  subjectId: grant.subjectId,
2407
3067
  sessionId: interaction.sessionId,
@@ -2480,31 +3140,22 @@ async function validatedSlackRequester(
2480
3140
  SlackInteraction,
2481
3141
  "accountId" | "workspaceId" | "connectionId" | "initiatingSlackUserId" | "owningSubjectId"
2482
3142
  >,
3143
+ // Identity links are HOME facts, unique on (connection_id, slack_user_id) and
3144
+ // RLS-visible only under the installation's own tenancy.
3145
+ home: { accountId: string; workspaceId: string },
2483
3146
  ) {
2484
3147
  if (!interaction.initiatingSlackUserId) {
2485
- return { authorized: false, canSchedule: false, mention: "" };
3148
+ return { authorized: false, mention: "" };
2486
3149
  }
2487
- const [link, grant] = await Promise.all([
2488
- getSlackBotUserLink(
2489
- deps.db,
2490
- interaction.workspaceId,
2491
- interaction.connectionId,
2492
- interaction.initiatingSlackUserId,
2493
- ),
2494
- getWorkspaceGrant(deps.db, interaction.owningSubjectId, interaction.workspaceId, {
2495
- principalKind: "human_session",
2496
- }),
2497
- ]);
3150
+ const link = await getSlackBotUserLink(
3151
+ deps.db,
3152
+ home.workspaceId,
3153
+ interaction.connectionId,
3154
+ interaction.initiatingSlackUserId,
3155
+ );
2498
3156
  const authorized = link?.subjectId === interaction.owningSubjectId;
2499
- const canSchedule =
2500
- authorized &&
2501
- grant?.accountId === interaction.accountId &&
2502
- hasPermission(grant.permissions, "sessions:read") &&
2503
- hasPermission(grant.permissions, "sessions:control") &&
2504
- hasPermission(grant.permissions, "scheduled_tasks:manage");
2505
3157
  return {
2506
3158
  authorized,
2507
- canSchedule,
2508
3159
  mention: authorized ? slackRequesterMention(interaction) : "",
2509
3160
  };
2510
3161
  }
@@ -2831,6 +3482,157 @@ async function slackHumanInputCard(
2831
3482
  return { text, blocks, operationId };
2832
3483
  }
2833
3484
 
3485
+ /**
3486
+ * A child of a Slack-originated session is never itself mapped to a Slack
3487
+ * thread, so the parent's bounded `child_requires_action` notice is the only
3488
+ * path that can tell the human that a worker they started is blocked. The card
3489
+ * is a pointer, not a second question card: one bounded preview of the first
3490
+ * question (or the waiting approval count) plus the child link. The child's own
3491
+ * OpenGeni card remains the place the human actually answers.
3492
+ *
3493
+ * Every deferred child lifecycle kind (`child_progress`,
3494
+ * `child_waiting_capacity`, `child_requires_action_resolved`, `child_paused`)
3495
+ * returns null here so Slack stays quiet, and so does every non-child machine
3496
+ * input that shares the `system.update.pending` event type.
3497
+ *
3498
+ * The caller has already proven that this workspace turned the notice on. The
3499
+ * card defaults off per workspace because the in-app rail and priority feed
3500
+ * already surface a blocked child.
3501
+ */
3502
+ /**
3503
+ * The exact durable notice behind one `system.update.pending` event, or null
3504
+ * when it no longer describes a blocked worker.
3505
+ *
3506
+ * Slack delivery runs behind the session: a widened retry window, a replica
3507
+ * claim, or simply a later page can reach this event after the child already
3508
+ * got its answer. Two facts have to agree before a card is worth posting.
3509
+ *
3510
+ * A resolution supersedes a still-`pending` notice in the same commit, so
3511
+ * `superseded` (and an explicitly `cancelled` notice) is already a "no longer
3512
+ * blocked" fact. But a notice the parent's turn has claimed is `delivered` and
3513
+ * keeps that state forever, so state alone is not enough - the resolution row
3514
+ * for that exact (child, turn, generation) boundary is checked as well, on the
3515
+ * parent's own rows. A re-freeze is a new generation and a new notice.
3516
+ *
3517
+ * Every read is wrapped: a malformed or unreadable durable row is not a
3518
+ * delivery failure. Throwing would burn a delivery attempt and, after
3519
+ * MAX_DELIVERY_ATTEMPTS, close this interaction's whole delivery over one
3520
+ * notice nobody could have posted anyway. Silence is the correct outcome, and
3521
+ * it is the same outcome every other unpostable notice already takes.
3522
+ */
3523
+ async function resolveSlackBlockedChildNotice(
3524
+ deps: ApiRouteDeps,
3525
+ interaction: SlackInteraction,
3526
+ sessionId: string,
3527
+ updateId: string,
3528
+ ): Promise<ChildRequiresActionPayload | null> {
3529
+ try {
3530
+ const update = await getSessionSystemUpdateById(
3531
+ deps.db,
3532
+ interaction.workspaceId,
3533
+ sessionId,
3534
+ updateId,
3535
+ );
3536
+ if (!update || update.payload.type !== "child_requires_action") return null;
3537
+ if (update.state === "superseded" || update.state === "cancelled") return null;
3538
+ const resolved = await childRequiresActionResolutionExists(
3539
+ deps.db,
3540
+ interaction.workspaceId,
3541
+ sessionId,
3542
+ {
3543
+ childSessionId: update.payload.childSessionId,
3544
+ childTurnId: update.payload.childTurnId,
3545
+ childTurnGeneration: update.payload.childTurnGeneration,
3546
+ },
3547
+ );
3548
+ return resolved ? null : update.payload;
3549
+ } catch {
3550
+ return null;
3551
+ }
3552
+ }
3553
+
3554
+ async function slackChildRequiresActionCard(
3555
+ deps: ApiRouteDeps,
3556
+ interaction: SlackInteraction,
3557
+ event: SessionEvent,
3558
+ mention: string,
3559
+ ): Promise<{ text: string } | null> {
3560
+ const sessionId = interaction.sessionId;
3561
+ if (!sessionId) return null;
3562
+ const preview = record(event.payload);
3563
+ if (boundedString(preview?.kind, 64) !== "child_requires_action") return null;
3564
+ const updateId = boundedString(preview?.updateId, 64);
3565
+ if (!updateId || !SLACK_UUID_PATTERN.test(updateId)) return null;
3566
+ // The bounded event preview is lossy by construction. Resolve the exact
3567
+ // durable notice under the ordinary workspace scope; the child session is
3568
+ // never read, only linked.
3569
+ const notice = await resolveSlackBlockedChildNotice(deps, interaction, sessionId, updateId);
3570
+ if (!notice) return null;
3571
+ const questions = notice.requests.filter((request) => request.kind === "human_input");
3572
+ const approvals = notice.requests.filter((request) => request.kind === "approval");
3573
+ const first = questions[0];
3574
+ const detail = first
3575
+ ? `${boundedSlackChildDetail(first.firstQuestion)}${
3576
+ first.questionCount > 1 ? ` (+${first.questionCount - 1} more)` : ""
3577
+ }`
3578
+ : approvals.length > 0
3579
+ ? `${approvals.length} tool approval${approvals.length > 1 ? "s are" : " is"} waiting for a human.`
3580
+ : "";
3581
+ const link = slackSessionUrl(deps, interaction.workspaceId, notice.childSessionId);
3582
+ const lines = [`${mention}A worker you started needs input.`];
3583
+ if (detail) lines.push(`> ${detail}`);
3584
+ if (link) lines.push(`<${link}|Open in OpenGeni>`);
3585
+ return { text: boundedOutput(lines.join("\n")) };
3586
+ }
3587
+
3588
+ /**
3589
+ * A goal that paused because it ran out of budget or hit the continuation cap
3590
+ * stops making progress with nobody watching, so the Slack thread gets one
3591
+ * bounded line. A `user_pause` / `api` / `agent` pause is a decision the human
3592
+ * or their agent already made, and `no_progress` is not a stop the human must
3593
+ * act on, so none of them post. `goal.resumed` never posts.
3594
+ *
3595
+ * The caller has already proven that this workspace turned the notice on; it
3596
+ * defaults off per workspace.
3597
+ */
3598
+ function slackGoalPausedText(
3599
+ deps: ApiRouteDeps,
3600
+ interaction: SlackInteraction,
3601
+ event: SessionEvent,
3602
+ mention: string,
3603
+ ): string | null {
3604
+ if (!interaction.sessionId) return null;
3605
+ const reason = boundedString(record(event.payload)?.reason, 64);
3606
+ const headline = reason ? SLACK_GOAL_PAUSED_HEADLINES.get(reason) : undefined;
3607
+ if (!headline) return null;
3608
+ const link = slackSessionUrl(deps, interaction.workspaceId, interaction.sessionId);
3609
+ return boundedOutput(`${mention}${headline}.${link ? ` <${link}|Open in OpenGeni>` : ""}`);
3610
+ }
3611
+
3612
+ /**
3613
+ * The installation tenancy that owns this interaction's Slack credential.
3614
+ *
3615
+ * `slack_interactions.connection_id` deliberately has no composite
3616
+ * `(workspace_id, connection_id)` foreign key, so a routed interaction may point
3617
+ * at the installation's connection from another workspace.
3618
+ *
3619
+ * A binding that does not resolve, or that now names a different connection, is
3620
+ * NOT turned into a new delivery precondition: this code previously used the
3621
+ * interaction's own tenancy unconditionally, so falling back to it keeps every
3622
+ * delivery that used to succeed succeeding. The fallback cannot post from the
3623
+ * wrong workspace either, because `claimSlackBotPostOperation` selects the
3624
+ * connection under the tenancy it is given and fails when it does not own it.
3625
+ */
3626
+ async function resolveSlackDeliveryHome(
3627
+ deps: ApiRouteDeps,
3628
+ interaction: Pick<SlackInteraction, "accountId" | "workspaceId" | "connectionId" | "slackTeamId">,
3629
+ ): Promise<{ accountId: string; workspaceId: string }> {
3630
+ const installation = await resolveSlackInstallationRoute(deps.db, interaction.slackTeamId);
3631
+ return installation && installation.connectionId === interaction.connectionId
3632
+ ? { accountId: installation.accountId, workspaceId: installation.workspaceId }
3633
+ : { accountId: interaction.accountId, workspaceId: interaction.workspaceId };
3634
+ }
3635
+
2834
3636
  async function deliverSlackSessionEvents(
2835
3637
  deps: ApiRouteDeps,
2836
3638
  interaction: SlackInteraction,
@@ -2850,14 +3652,65 @@ async function deliverSlackSessionEvents(
2850
3652
  });
2851
3653
  return;
2852
3654
  }
3655
+ // The bot credential is owned by the installation's workspace and every
3656
+ // provider call is fenced on it, so the delivery client is always built from
3657
+ // HOME even when the session it reports on lives in another workspace.
3658
+ const home = await resolveSlackDeliveryHome(deps, interaction);
2853
3659
  const client = await createOpenGeniSlackBotInteractionClient(deps, {
2854
- accountId: interaction.accountId,
2855
- workspaceId: interaction.workspaceId,
3660
+ ...home,
2856
3661
  connectionId: interaction.connectionId,
2857
3662
  subjectId: SLACK_INTERACTION_BOT_SUBJECT_ID,
2858
3663
  sessionId: interaction.sessionId,
2859
3664
  });
2860
- const requester = await validatedSlackRequester(deps, interaction);
3665
+ const requester = await validatedSlackRequester(deps, interaction, home);
3666
+ // Both orchestration notices are per-workspace and OFF unless this workspace
3667
+ // turned them on. Resolved lazily so an ordinary page of turn/progress events
3668
+ // costs no extra workspace read, and memoized so one page resolves once. A
3669
+ // disabled notice takes the same "nothing to post for this event" path as an
3670
+ // undeliverable one: no post operation, no progress slot, and the delivery
3671
+ // cursor still advances past the event exactly as it would have.
3672
+ let orchestrationNotices: ResolvedWorkspaceSlackOrchestrationNoticeSettings | null = null;
3673
+ const orchestrationNoticeEnabled = async (
3674
+ notice: keyof ResolvedWorkspaceSlackOrchestrationNoticeSettings,
3675
+ ): Promise<boolean> => {
3676
+ orchestrationNotices ??= resolveWorkspaceSlackOrchestrationNoticeSettings(
3677
+ (await getWorkspace(deps.db, interaction.workspaceId))?.settings,
3678
+ );
3679
+ return orchestrationNotices[notice];
3680
+ };
3681
+ /**
3682
+ * Post one orchestration notice through the SAME durable per-interaction slot
3683
+ * budget as assistant progress, so an orchestration that fans out to many
3684
+ * blocked children cannot turn one Slack task into an unbounded feed. That
3685
+ * budget is deliberately shared rather than a second private allowance: the
3686
+ * ceiling worth enforcing is the total number of posts the human did not ask
3687
+ * for, not a per-category one, and this whole feature exists because an
3688
+ * unsolicited post is worse than a missed one. Beyond the cap the notice goes
3689
+ * silent exactly like a fourth progress message.
3690
+ *
3691
+ * The slot is claimed only once a card is actually going to be posted, so a
3692
+ * skipped, stale, or already-resolved notice never burns one. Claims are
3693
+ * durable and keyed on the session event sequence, so a reaper retry, a
3694
+ * replica claim, or a replayed page reuses the same slot and the same post
3695
+ * operation instead of posting twice or consuming a second slot. The claimed
3696
+ * row never becomes terminal-coalescing evidence: that lookup only joins
3697
+ * `agent.message.completed` events.
3698
+ */
3699
+ const postUnsolicitedNotice = async (event: SessionEvent, text: string, kind: string) => {
3700
+ const slot = await claimSlackInteractionProgressDelivery(deps.db, {
3701
+ accountId: interaction.accountId,
3702
+ workspaceId: interaction.workspaceId,
3703
+ interactionId: interaction.id,
3704
+ claimHolderId,
3705
+ sessionEventSequence: event.sequence,
3706
+ maxProgress: MAX_PROGRESS_MESSAGES,
3707
+ });
3708
+ if (slot.kind === "not_owned") {
3709
+ throw new Error("Slack orchestration notice lost its durable interaction claim");
3710
+ }
3711
+ if (slot.kind !== "claimed") return;
3712
+ await postDelivery(client, interaction, event, text, kind, slot.delivery.operationId);
3713
+ };
2861
3714
  let lastSequence = interaction.lastDeliveredSessionEventSequence;
2862
3715
  let terminal: Exclude<SlackInteraction["terminalDeliveryState"], "open"> | null = null;
2863
3716
  let latestAssistantText = "";
@@ -2985,14 +3838,21 @@ async function deliverSlackSessionEvents(
2985
3838
  card.blocks,
2986
3839
  );
2987
3840
  }
3841
+ } else if (event.type === "system.update.pending") {
3842
+ const card = (await orchestrationNoticeEnabled("childRequiresAction"))
3843
+ ? await slackChildRequiresActionCard(deps, interaction, event, requester.mention)
3844
+ : null;
3845
+ if (card) await postUnsolicitedNotice(event, card.text, "child-blocked");
3846
+ } else if (event.type === "goal.paused") {
3847
+ const paused = (await orchestrationNoticeEnabled("goalPaused"))
3848
+ ? slackGoalPausedText(deps, interaction, event, requester.mention)
3849
+ : null;
3850
+ if (paused) await postUnsolicitedNotice(event, paused, "goal-paused");
2988
3851
  } else if (event.type === "turn.completed") {
2989
3852
  const payloadOutput = safePayloadText(event.payload, "output");
2990
3853
  const hasPublishableOutput = payloadOutput.trim().length > 0;
2991
3854
  const output = hasPublishableOutput ? payloadOutput : latestAssistantText;
2992
3855
  const normalizedOutput = output.trim();
2993
- const recurringLink = requester.canSchedule
2994
- ? `\n\n${makeRecurringText(deps, interaction.workspaceId, interaction.sessionId)}`
2995
- : "";
2996
3856
  const existingProgress = progressEvidence.find(
2997
3857
  (delivery) =>
2998
3858
  slackDeliveryTextsCoalesce(boundedOutput(delivery.text).trim(), normalizedOutput) &&
@@ -3022,15 +3882,14 @@ async function deliverSlackSessionEvents(
3022
3882
  );
3023
3883
  const posted = await getSlackBotPostOperation(
3024
3884
  deps.db,
3025
- interaction.workspaceId,
3885
+ home.workspaceId,
3026
3886
  interaction.connectionId,
3027
3887
  existingProgress.operationId,
3028
3888
  );
3029
3889
  if (posted?.slackChannelId && posted.slackMessageTimestamp) {
3030
- const text = boundedOutputWithSuffix(
3031
- `${requester.mention}${existingProgress.text}`,
3032
- recurringLink,
3033
- );
3890
+ // The result is the result. Continuation prose and the recurring
3891
+ // action live on the control/Status card and `<command> info`.
3892
+ const text = boundedOutput(`${requester.mention}${existingProgress.text}`);
3034
3893
  await client.updateMessage({
3035
3894
  operationId: deterministicUuid(
3036
3895
  `slack-terminal-update:${interaction.id}:${event.sequence}`,
@@ -3058,10 +3917,8 @@ async function deliverSlackSessionEvents(
3058
3917
  const operationId = deterministicUuid(
3059
3918
  `slack-delivery:${interaction.id}:${event.sequence}:final`,
3060
3919
  );
3061
- const finalSuffix = `\n\nReply in this thread to continue.${recurringLink}`;
3062
- const text = boundedOutputWithSuffix(
3920
+ const text = boundedOutput(
3063
3921
  `${requester.mention}${output || "OpenGeni finished this task."}`,
3064
- finalSuffix,
3065
3922
  );
3066
3923
  const publicationBlocks = hasPublishableOutput
3067
3924
  ? await slackSharedResultPublicationBlocks(
@@ -3311,7 +4168,14 @@ function humanInputResponse(questions: HumanInputQuestion[], text: string) {
3311
4168
  const matches = question.options.filter(
3312
4169
  (option) => option.id.toLowerCase() === normalized || option.label.toLowerCase() === normalized,
3313
4170
  );
3314
- if (matches.length !== 1) return null;
4171
+ if (matches.length !== 1) {
4172
+ const other = text.trim();
4173
+ if (!other) return null;
4174
+ return {
4175
+ outcome: "answered" as const,
4176
+ answers: [{ questionId: question.id, values: [], other }],
4177
+ };
4178
+ }
3315
4179
  return {
3316
4180
  outcome: "answered" as const,
3317
4181
  answers: [{ questionId: question.id, values: [matches[0]!.id] }],
@@ -3326,11 +4190,123 @@ function formatQuestions(questions: HumanInputQuestion[]) {
3326
4190
  .slice(0, 10)
3327
4191
  .map((option) => option.label)
3328
4192
  .join(", ");
3329
- return `${index + 1}. ${boundedOutput(question.prompt)}${options ? ` (${options})` : ""}`;
4193
+ return `${index + 1}. ${boundedOutput(question.prompt)}${
4194
+ options ? ` (${options}, or reply with another value)` : ""
4195
+ }`;
3330
4196
  })
3331
4197
  .join("\n");
3332
4198
  }
3333
4199
 
4200
+ export function isSlackInfoCommand(text: string): boolean {
4201
+ return text.trim().toLowerCase() === "info";
4202
+ }
4203
+
4204
+ type SlackSlashResponse = {
4205
+ response_type: "ephemeral";
4206
+ text: string;
4207
+ unfurl_links: false;
4208
+ unfurl_media: false;
4209
+ blocks?: SlackMessageBlock[];
4210
+ };
4211
+
4212
+ /**
4213
+ * The ephemeral `<command> info` card.
4214
+ *
4215
+ * It is a projection of what the caller can already do, not an action: no
4216
+ * session, no durable inbox row, no provider post. It re-proves the exact
4217
+ * Slack identity link plus live workspace grants before any
4218
+ * workspace-identifying text is echoed, so an unlinked or access-revoked
4219
+ * identity receives the ordinary connect view instead.
4220
+ */
4221
+ async function slackInfoCommandResponse(
4222
+ deps: ApiRouteDeps,
4223
+ installation: SlackInstallationRoute,
4224
+ entry: NormalizedSlackInteraction,
4225
+ ): Promise<SlackSlashResponse> {
4226
+ const identity = {
4227
+ workspaceId: installation.workspaceId,
4228
+ connectionId: installation.connectionId,
4229
+ slackTeamId: entry.slackTeamId,
4230
+ slackUserId: entry.slackUserId,
4231
+ };
4232
+ const link = await getSlackBotUserLink(
4233
+ deps.db,
4234
+ installation.workspaceId,
4235
+ installation.connectionId,
4236
+ entry.slackUserId,
4237
+ );
4238
+ const grant = link
4239
+ ? await getWorkspaceGrant(deps.db, link.subjectId, installation.workspaceId, {
4240
+ principalKind: "human_session",
4241
+ })
4242
+ : null;
4243
+ if (
4244
+ !grant ||
4245
+ grant.accountId !== installation.accountId ||
4246
+ !hasPermission(grant.permissions, "sessions:read")
4247
+ ) {
4248
+ return ephemeralSlackResponse(
4249
+ link
4250
+ ? `Your Slack identity is linked, but it does not currently have access to this OpenGeni workspace. Request access: ${linkUrl(deps, identity)}. No session was created.`
4251
+ : `Link your Slack identity to OpenGeni before starting work: ${linkUrl(deps, identity)}. No session was created.`,
4252
+ );
4253
+ }
4254
+ const workspace = await getWorkspace(deps.db, installation.workspaceId);
4255
+ const command = deps.settings.slackCommand;
4256
+ const botMention = slackBotMention(installation.botUserId);
4257
+ // Every line is gated on the grant that actually authorizes it, so the card
4258
+ // stays a projection of what this caller can do rather than a generic manual.
4259
+ const canControl = hasPermission(grant.permissions, "sessions:control");
4260
+ const canCreate = hasPermission(grant.permissions, "sessions:create");
4261
+ const schedules = hasPermission(grant.permissions, "scheduled_tasks:manage")
4262
+ ? slackSchedulesUrl(deps, installation.workspaceId)
4263
+ : null;
4264
+ const workspaceUrl = slackWorkspaceUrl(deps, installation.workspaceId);
4265
+ const workspaceName = (workspace?.name ?? "").trim().slice(0, 120);
4266
+ const destination = workspaceName
4267
+ ? `the *${escapeSlackMrkdwn(workspaceName)}* workspace`
4268
+ : "your OpenGeni workspace";
4269
+ const lines = [
4270
+ "*Working with OpenGeni in Slack*",
4271
+ "",
4272
+ ...(canControl
4273
+ ? [
4274
+ "• *Continue a task:* reply in its Slack thread.",
4275
+ "• *Stop a task:* press *Stop* on its card, or reply `stop` in its thread.",
4276
+ ]
4277
+ : []),
4278
+ ...(canCreate
4279
+ ? [
4280
+ `• *Start a new task:* mention ${botMention} in a channel, run \`${command} <task>\`, or send a new top-level direct message.`,
4281
+ ]
4282
+ : []),
4283
+ ...(schedules
4284
+ ? [
4285
+ `• *Make a result recurring:* press *Status* on the task card and open *Make recurring*, or open <${schedules}|Schedules>.`,
4286
+ ]
4287
+ : []),
4288
+ `• *Where work lands:* ${destination}${workspaceUrl ? ` (<${workspaceUrl}|open OpenGeni>)` : ""}.`,
4289
+ ];
4290
+ const text = lines.join("\n");
4291
+ return ephemeralSlackResponse(text, [
4292
+ { type: "section", text: { type: "mrkdwn", text } },
4293
+ ] as SlackMessageBlock[]);
4294
+ }
4295
+
4296
+ function ephemeralSlackResponse(text: string, blocks?: SlackMessageBlock[]): SlackSlashResponse {
4297
+ return {
4298
+ response_type: "ephemeral",
4299
+ text,
4300
+ unfurl_links: false,
4301
+ unfurl_media: false,
4302
+ ...(blocks ? { blocks } : {}),
4303
+ };
4304
+ }
4305
+
4306
+ function slackBotMention(botUserId: string | null): string {
4307
+ return botUserId && /^[UWB][A-Z0-9]{1,63}$/.test(botUserId) ? `<@${botUserId}>` : "@OpenGeni";
4308
+ }
4309
+
3334
4310
  function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
3335
4311
  const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
3336
4312
  if (!base) throw new Error("Slack session acknowledgement requires an absolute web base URL");
@@ -3338,15 +4314,95 @@ function openSessionText(deps: ApiRouteDeps, workspaceId: string, sessionId: str
3338
4314
  return `<${url}|Open in OpenGeni>`;
3339
4315
  }
3340
4316
 
3341
- function makeRecurringText(deps: ApiRouteDeps, workspaceId: string, sessionId: string) {
4317
+ function slackWorkspaceUrl(deps: ApiRouteDeps, workspaceId: string): string | null {
4318
+ return safeSlackActionUrl(deps, `/workspaces/${encodeURIComponent(workspaceId)}`);
4319
+ }
4320
+
4321
+ function slackSessionAuthorizationScopeKey(scope: SessionAuthorizationListScope | null): string {
4322
+ if (scope === null) return "standalone";
4323
+ if (scope.kind === "all") return "all";
4324
+ return JSON.stringify({
4325
+ kind: scope.kind,
4326
+ rootSessionIds: [...scope.rootSessionIds].sort(),
4327
+ sessionIds: [...scope.sessionIds].sort(),
4328
+ });
4329
+ }
4330
+
4331
+ function slackSessionUrl(
4332
+ deps: ApiRouteDeps,
4333
+ workspaceId: string,
4334
+ sessionId: string,
4335
+ ): string | null {
4336
+ return safeSlackActionUrl(
4337
+ deps,
4338
+ `/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
4339
+ );
4340
+ }
4341
+
4342
+ function safeSlackActionUrl(deps: ApiRouteDeps, pathname: string): string | null {
3342
4343
  const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
3343
- if (!base) throw new Error("Slack recurring action requires an absolute web base URL");
3344
- const url = new URL(`/workspaces/${workspaceId}/schedules`, base);
4344
+ if (!base) return null;
4345
+ try {
4346
+ const url = new URL(pathname, base);
4347
+ if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) {
4348
+ return null;
4349
+ }
4350
+ return url.toString();
4351
+ } catch {
4352
+ return null;
4353
+ }
4354
+ }
4355
+
4356
+ function slackAppHomeLinkUrl(
4357
+ deps: ApiRouteDeps,
4358
+ installation: SlackInstallationRoute,
4359
+ slackTeamId: string,
4360
+ slackUserId: string,
4361
+ ): string | null {
4362
+ const signingSecret = deps.settings.slackSigningSecret;
4363
+ const base = slackWorkspaceUrl(deps, installation.workspaceId);
4364
+ if (!signingSecret || !base) return base;
4365
+ const url = new URL(
4366
+ `/workspaces/${encodeURIComponent(installation.workspaceId)}/capabilities`,
4367
+ base,
4368
+ );
4369
+ url.hash = new URLSearchParams({
4370
+ slack_link: createSlackUserLinkToken(signingSecret, {
4371
+ workspaceId: installation.workspaceId,
4372
+ connectionId: installation.connectionId,
4373
+ slackTeamId,
4374
+ slackUserId,
4375
+ }),
4376
+ }).toString();
4377
+ return url.toString();
4378
+ }
4379
+
4380
+ function makeRecurringText(
4381
+ deps: ApiRouteDeps,
4382
+ workspaceId: string,
4383
+ sessionId: string,
4384
+ ): string | null {
4385
+ // Unchanged deep-link contract: the schedules editor plus the exact source
4386
+ // session UUID, and nothing copied out of Slack. A deployment without an
4387
+ // absolute web base URL simply omits the action instead of failing a card.
4388
+ const base = slackSchedulesUrl(deps, workspaceId);
4389
+ if (!base) return null;
4390
+ const url = new URL(base);
3345
4391
  url.searchParams.set("sourceSessionId", sessionId);
3346
4392
  return `<${url.toString()}|Make recurring>`;
3347
4393
  }
3348
4394
 
3349
- function linkUrl(deps: ApiRouteDeps, entry: SlackInteractionInboxEntry) {
4395
+ function slackSchedulesUrl(deps: ApiRouteDeps, workspaceId: string): string | null {
4396
+ return safeSlackActionUrl(deps, `/workspaces/${encodeURIComponent(workspaceId)}/schedules`);
4397
+ }
4398
+
4399
+ function linkUrl(
4400
+ deps: ApiRouteDeps,
4401
+ entry: Pick<
4402
+ SlackInteractionInboxEntry,
4403
+ "workspaceId" | "connectionId" | "slackTeamId" | "slackUserId"
4404
+ >,
4405
+ ) {
3350
4406
  const base = deps.settings.webBaseUrl ?? deps.settings.publicBaseUrl;
3351
4407
  const signingSecret = deps.settings.slackSigningSecret;
3352
4408
  if (!base || !signingSecret) return "OpenGeni Settings → Integrations → Slack";
@@ -3519,19 +4575,20 @@ function boundedText(value: unknown): string | null {
3519
4575
  return trimmed.slice(0, MAX_SLACK_INPUT_CHARS);
3520
4576
  }
3521
4577
 
4578
+ /** Single-line, character-bounded preview for a blocked-worker pointer card. */
4579
+ function boundedSlackChildDetail(value: string) {
4580
+ const collapsed = value.replace(/\s+/gu, " ").trim();
4581
+ return collapsed.length <= MAX_SLACK_CHILD_DETAIL_CHARS
4582
+ ? collapsed
4583
+ : `${collapsed.slice(0, MAX_SLACK_CHILD_DETAIL_CHARS - 1)}…`;
4584
+ }
4585
+
3522
4586
  function boundedOutput(value: string) {
3523
4587
  return value.length <= MAX_SLACK_TEXT_CHARS
3524
4588
  ? value
3525
4589
  : `${value.slice(0, MAX_SLACK_TEXT_CHARS - 20)}\n… output truncated`;
3526
4590
  }
3527
4591
 
3528
- function boundedOutputWithSuffix(value: string, suffix: string) {
3529
- const maxValueChars = Math.max(0, MAX_SLACK_TEXT_CHARS - suffix.length);
3530
- if (value.length <= maxValueChars) return `${value}${suffix}`;
3531
- const truncation = "\n… output truncated";
3532
- return `${value.slice(0, Math.max(0, maxValueChars - truncation.length))}${truncation}${suffix}`;
3533
- }
3534
-
3535
4592
  function safePayloadText(payload: unknown, field: string) {
3536
4593
  const value = record(payload)?.[field];
3537
4594
  return typeof value === "string" ? boundedOutput(value) : "";