@opengeni/api-router 2.1.0-canary.0 → 2.3.2-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 (44) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth.d.ts +5 -2
  3. package/dist/auth/managed-email.d.ts +29 -0
  4. package/dist/auth/organization-user-setup.d.ts +57 -0
  5. package/dist/{chunk-QKDFBBUE.js → chunk-IBV7Z6F4.js} +3872 -1845
  6. package/dist/chunk-IBV7Z6F4.js.map +1 -0
  7. package/dist/index.js +1 -1
  8. package/dist/integrations/slack-app-home.d.ts +1 -1
  9. package/dist/integrations/slack-bot.d.ts +8 -0
  10. package/dist/integrations/slack-interactions.d.ts +35 -2
  11. package/dist/mcp/server.d.ts +1 -1
  12. package/dist/mcp/session-view.d.ts +1 -0
  13. package/dist/routes/automations.d.ts +13 -0
  14. package/dist/routes/insights.d.ts +2 -1
  15. package/dist/routes/managed-onboarding.d.ts +29 -0
  16. package/dist/routes/pr-review-github.d.ts +3 -0
  17. package/package.json +18 -18
  18. package/src/app.ts +64 -5
  19. package/src/auth/managed-auth.ts +29 -34
  20. package/src/auth/managed-email.ts +174 -0
  21. package/src/auth/organization-user-setup.ts +217 -0
  22. package/src/http/auth.ts +15 -0
  23. package/src/http/sse.ts +62 -13
  24. package/src/integrations/slack-app-home.ts +2 -2
  25. package/src/integrations/slack-bot.ts +5 -0
  26. package/src/integrations/slack-interactions.ts +653 -71
  27. package/src/integrations/slack-routing.ts +25 -12
  28. package/src/mcp/company-brain-governed-writes.ts +4 -4
  29. package/src/mcp/company-profile-agent-admin.ts +11 -18
  30. package/src/mcp/remember.ts +4 -4
  31. package/src/mcp/server.ts +50 -4
  32. package/src/mcp/session-view.ts +8 -2
  33. package/src/routes/automations.ts +3 -3
  34. package/src/routes/documents.ts +136 -3
  35. package/src/routes/insights.ts +61 -19
  36. package/src/routes/managed-onboarding.ts +317 -0
  37. package/src/routes/organization-memberships.ts +212 -155
  38. package/src/routes/pr-review-github.ts +844 -0
  39. package/src/routes/pr-review.ts +20 -0
  40. package/src/routes/rigs.ts +37 -4
  41. package/src/routes/sessions.ts +101 -0
  42. package/src/sandbox/channel-a.ts +34 -7
  43. package/src/sandbox/viewer.ts +47 -11
  44. package/dist/chunk-QKDFBBUE.js.map +0 -1
@@ -175,11 +175,24 @@ function labelFor(
175
175
  * override beats configuration beats derivation beats asking.
176
176
  */
177
177
  export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteResolution {
178
+ // A personal workspace is only ever a destination for that person's own bot
179
+ // DM. Offering it in a channel would be wrong twice over: routing a shared
180
+ // conversation into one member's private space hides it from everyone else
181
+ // in the channel, and - because managed tenancy provisions a personal
182
+ // workspace for every member - counting it as a candidate means nobody ever
183
+ // has exactly one. That defeats the sole-candidate rule below, so an
184
+ // organization with a single shared workspace would be asked to choose in
185
+ // every channel despite having no choice to make.
186
+ const directMessage = isSlackDirectMessageConversation(input.entry);
187
+ const candidates = directMessage
188
+ ? input.candidates
189
+ : input.candidates.filter((candidate) => !candidate.personal);
190
+
178
191
  const installation = {
179
192
  kind: "resolved" as const,
180
193
  accountId: input.home.accountId,
181
194
  workspaceId: input.home.workspaceId,
182
- label: labelFor(input.candidates, input.home.workspaceId),
195
+ label: labelFor(candidates, input.home.workspaceId),
183
196
  source: "installation" as const,
184
197
  };
185
198
 
@@ -206,7 +219,7 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
206
219
  kind: "resolved",
207
220
  accountId: input.threadTenancy.accountId,
208
221
  workspaceId: input.threadTenancy.workspaceId,
209
- label: labelFor(input.candidates, input.threadTenancy.workspaceId),
222
+ label: labelFor(candidates, input.threadTenancy.workspaceId),
210
223
  source: "thread",
211
224
  };
212
225
  }
@@ -221,13 +234,13 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
221
234
  ? parseSlackWorkspacePrefix(splitSlackLeadingMention(input.entry.text, input.botUserId).rest)
222
235
  : null;
223
236
  if (prefix) {
224
- const named = matchCandidate(input.candidates, prefix.requested);
237
+ const named = matchCandidate(candidates, prefix.requested);
225
238
  if (named === "ambiguous" || !named) {
226
239
  return {
227
240
  kind: "denied",
228
241
  reason: "no_access_to_named",
229
242
  requested: prefix.requested,
230
- candidates: input.candidates,
243
+ candidates: candidates,
231
244
  };
232
245
  }
233
246
  return {
@@ -245,7 +258,7 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
245
258
  kind: "resolved",
246
259
  accountId: input.channelRoute.targetAccountId,
247
260
  workspaceId: input.channelRoute.targetWorkspaceId,
248
- label: labelFor(input.candidates, input.channelRoute.targetWorkspaceId),
261
+ label: labelFor(candidates, input.channelRoute.targetWorkspaceId),
249
262
  source: "channel",
250
263
  };
251
264
  }
@@ -254,13 +267,13 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
254
267
  // lands in their own workspace unless they chose otherwise. The personal
255
268
  // workspace id is DERIVED from an active organization membership pointer;
256
269
  // it is never accepted from a Slack payload or a route row.
257
- if (isSlackDirectMessageConversation(input.entry)) {
270
+ if (directMessage) {
258
271
  if (input.dmRoute) {
259
272
  return {
260
273
  kind: "resolved",
261
274
  accountId: input.dmRoute.targetAccountId,
262
275
  workspaceId: input.dmRoute.targetWorkspaceId,
263
- label: labelFor(input.candidates, input.dmRoute.targetWorkspaceId),
276
+ label: labelFor(candidates, input.dmRoute.targetWorkspaceId),
264
277
  source: "dm_route",
265
278
  };
266
279
  }
@@ -269,7 +282,7 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
269
282
  kind: "resolved",
270
283
  accountId: input.home.accountId,
271
284
  workspaceId: input.personalWorkspaceId,
272
- label: labelFor(input.candidates, input.personalWorkspaceId),
285
+ label: labelFor(candidates, input.personalWorkspaceId),
273
286
  source: "dm_personal",
274
287
  };
275
288
  }
@@ -280,8 +293,8 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
280
293
 
281
294
  // 5. One workspace is not a choice. This is what keeps the flag quiet for
282
295
  // installs that only ever had one workspace.
283
- const sole = input.candidates[0];
284
- if (input.candidates.length === 1 && sole) {
296
+ const sole = candidates[0];
297
+ if (candidates.length === 1 && sole) {
285
298
  return {
286
299
  kind: "resolved",
287
300
  accountId: sole.accountId,
@@ -291,13 +304,13 @@ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteR
291
304
  };
292
305
  }
293
306
 
294
- if (input.candidates.length === 0) {
307
+ if (candidates.length === 0) {
295
308
  return { kind: "denied", reason: "no_candidates", requested: null, candidates: [] };
296
309
  }
297
310
 
298
311
  // 6. Genuinely ambiguous. Until the picker exists, keep the installation's
299
312
  // workspace rather than inventing an answer.
300
- return input.askEnabled ? { kind: "ask", candidates: input.candidates } : installation;
313
+ return input.askEnabled ? { kind: "ask", candidates: candidates } : installation;
301
314
  }
302
315
 
303
316
  /**
@@ -125,7 +125,7 @@ export function registerCompanyBrainGovernedWriteTools(
125
125
  {
126
126
  description:
127
127
  "Atomically promote one still-active note from this exact root task tree into an inactive workspace instruction-policy draft. The note bytes remain exact evidence and draft content. " +
128
- `Once a human activates the draft, those bytes are composed verbatim into the prompt of every session the target applies to, so a note over ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters is rejected here rather than truncated: write a fresh short imperative note instead of promoting a long working note. ` +
128
+ `Use this only for a universal always-on rule, never for an incident, fact, decision, outcome, or conditional procedure. Once a human activates the draft, those bytes are composed verbatim into the prompt of every session the target applies to, so a note over ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters is rejected here rather than truncated: write a fresh minimal imperative note instead of promoting a long working note. ` +
129
129
  "The frozen learning policy records a decision receipt, but mandatory policy still requires human activation even under Automatic; this never widens scope.",
130
130
  inputSchema: {
131
131
  ...taskNotePromotion,
@@ -149,7 +149,7 @@ export function registerCompanyBrainGovernedWriteTools(
149
149
  "task_note_promote_preference",
150
150
  {
151
151
  description:
152
- "Atomically promote one still-active note from this exact root task tree into a workspace preference proposal. The note bytes remain exact evidence and full proposal content. " +
152
+ "Atomically promote one still-active note from this exact root task tree into a workspace Skill proposal backed by the structured preference authority. Use this for reusable conditional how-to guidance, never for an incident, fact, decision, outcome, or universal always-on rule. The note bytes remain exact evidence and full proposal content. " +
153
153
  `The title and description you supply are what gets composed into every session prompt, so write them as one short imperative statement; the note content is retrieved on demand and a note over ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters is rejected here rather than truncated. ` +
154
154
  "Under Suggest the proposal waits for human review; under Automatic an eligible decision is activated through the preference lifecycle and remains undoable. This never widens scope.",
155
155
  inputSchema: {
@@ -186,7 +186,7 @@ export function registerCompanyBrainGovernedWriteTools(
186
186
  {
187
187
  description:
188
188
  "Materialize an evidence-backed inactive workspace instruction-policy draft. " +
189
- `Once a human activates it, this content is composed verbatim into the prompt of every session the target applies to (every session in this workspace for a global charter or policy, every session bound to the role for a role policy), so keep it under ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
189
+ `Use this only for a minimal universal rule, never for an incident, fact, decision, outcome, or conditional procedure. Once a human activates it, this content is composed verbatim into the prompt of every session the target applies to (every session in this workspace for a global charter or policy, every session bound to the role for a role policy), so keep it under ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
190
190
  "The frozen learning policy records a decision receipt, but this tool cannot activate mandatory behavior, including when learning mode is Automatic; a human must activate the draft.",
191
191
  inputSchema: {
192
192
  ...evidence,
@@ -219,7 +219,7 @@ export function registerCompanyBrainGovernedWriteTools(
219
219
  "preference_propose",
220
220
  {
221
221
  description:
222
- "Materialize an evidence-backed workspace preference proposal. " +
222
+ "Materialize an evidence-backed workspace Skill proposal in the structured preference authority. Use this only for reusable conditional how-to guidance, never for an incident, fact, decision, outcome, or universal always-on rule. " +
223
223
  `Its short title and description are what get composed into every session prompt; the content is retrieved on demand, so its length is retrieval cost rather than standing prompt cost. Keep the content under ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
224
224
  "Under Suggest it stays inactive for human review; under Automatic an eligible decision is activated through the governed preference lifecycle with an undoable receipt. It never creates mandatory authority.",
225
225
  inputSchema: {
@@ -1,10 +1,8 @@
1
1
  import {
2
- AGENT_AUTHORED_COMPANY_PROFILE_CONTENT_MAX_UTF8_BYTES,
3
2
  AGENT_AUTHORED_COMPANY_PROFILE_ENTRY_MAX_CHARS,
4
3
  AGENT_AUTHORED_COMPANY_PROFILE_SCALAR_MAX_CHARS,
5
4
  AGENT_AUTHORED_COMPANY_PROFILE_TOO_LONG_MESSAGE,
6
5
  AgentAuthoredCompanyProfileContent,
7
- COMPANY_PROFILE_ENTRY_MAX_COUNT,
8
6
  COMPANY_PROFILE_REASON_MAX_CHARS,
9
7
  COMPANY_PROFILE_STABLE_KEY_MAX_CHARS,
10
8
  normalizeCompanyProfileStableKey,
@@ -32,9 +30,9 @@ export type RegisterCompanyProfileAgentAdminToolsInput = {
32
30
  router?: Pick<ReturnType<typeof createCompanyProfileAgentAdminRouter>, "propose" | "confirm">;
33
31
  };
34
32
 
35
- // Agent-only bounds. The human `account:admin` route keeps the wider
36
- // `COMPANY_PROFILE_*` limits; this profile is the largest always-on prompt
37
- // surface in the product, so an agent gets a much smaller budget for it.
33
+ // Agent-only bounds. The human `account:admin` API keeps the wider historical
34
+ // `COMPANY_PROFILE_*` limits; the current agent tool authors only the two
35
+ // always-on identity fields and therefore gets a much smaller budget.
38
36
  const scalar = z
39
37
  .string()
40
38
  .trim()
@@ -55,8 +53,7 @@ const entry = z.object({
55
53
  AGENT_AUTHORED_COMPANY_PROFILE_TOO_LONG_MESSAGE,
56
54
  ),
57
55
  });
58
- const entries = z.array(entry).max(COMPANY_PROFILE_ENTRY_MAX_COUNT);
59
- const DEFAULT_PROPOSAL_REASON = "Activate agent-proposed organization company profile";
56
+ const DEFAULT_PROPOSAL_REASON = "Activate agent-proposed organization identity";
60
57
  const STABLE_KEY_WORDS = 6;
61
58
 
62
59
  type EntryInput = z.infer<typeof entry>;
@@ -127,18 +124,14 @@ export function registerCompanyProfileAgentAdminTools(
127
124
  "company_profile_propose",
128
125
  {
129
126
  description:
130
- "Prepare one complete organization company profile covering identity, mission, products, customers, strategic goals, and critical constraints. Omitted list keys are derived from content. " +
131
- "Once activated, every field here is mandatory prompt context in every session for the whole organization, so write it as a concise profile rather than a document: one plain descriptive statement per field, no numbered procedure and no marketing copy. Longer source material belongs in organization Documents and is retrieved as evidence; never copy it into the profile. " +
132
- `Bounds for this tool are ${AGENT_AUTHORED_COMPANY_PROFILE_SCALAR_MAX_CHARS} characters for identity and mission, ${AGENT_AUTHORED_COMPANY_PROFILE_ENTRY_MAX_CHARS} per list entry, ${COMPANY_PROFILE_ENTRY_MAX_COUNT} entries per list, and ${AGENT_AUTHORED_COMPANY_PROFILE_CONTENT_MAX_UTF8_BYTES} UTF-8 bytes for the whole profile. ` +
127
+ "Prepare the organization's small, stable identity: identity says who the organization is, and mission says why it exists. " +
128
+ "Once activated, both fields are mandatory prompt context in every root session for the whole organization, so use one plain descriptive statement per field with no products, customers, goals, constraints, procedures, or marketing copy. Those details belong in organization-scoped Documents and are retrieved only when relevant. " +
129
+ `Each field is bounded to ${AGENT_AUTHORED_COMPANY_PROFILE_SCALAR_MAX_CHARS} characters for agent-authored proposals. ` +
133
130
  "This creates only an immutable inactive proposal for the exact live turn initiated by the organization owner and does not use workspace learning policy. The receipt returns the exact `humanInput` payload; call `request_human_input` with it verbatim, then call `company_profile_confirm` with the returned requestId.",
134
131
  inputSchema: {
135
132
  operationId: z.string().uuid(),
136
133
  identity: scalar,
137
134
  mission: scalar,
138
- products: entries,
139
- customers: entries,
140
- goals: entries,
141
- constraints: entries,
142
135
  reason: z.string().trim().min(1).max(COMPANY_PROFILE_REASON_MAX_CHARS).optional(),
143
136
  },
144
137
  },
@@ -147,10 +140,10 @@ export function registerCompanyProfileAgentAdminTools(
147
140
  const parsed = AgentAuthoredCompanyProfileContent.safeParse({
148
141
  identity: request.identity,
149
142
  mission: request.mission,
150
- products: resolveCompanyProfileEntries(request.products),
151
- customers: resolveCompanyProfileEntries(request.customers),
152
- goals: resolveCompanyProfileEntries(request.goals),
153
- constraints: resolveCompanyProfileEntries(request.constraints),
143
+ products: [],
144
+ customers: [],
145
+ goals: [],
146
+ constraints: [],
154
147
  });
155
148
  if (!parsed.success) {
156
149
  return input.json({
@@ -70,9 +70,9 @@ export function registerRememberTools(input: RegisterRememberToolsInput): void {
70
70
  "remember",
71
71
  {
72
72
  description:
73
- "Durably remember something the user explicitly asked to keep for this workspace. Use lane=preference for how agents should act, lane=instruction_policy only when the user stated a hard always/never rule, lane=knowledge for a company/product/people fact. " +
74
- `Write it short. A lane=instruction_policy rule is composed verbatim into the prompt of every session it applies to (every session in this workspace for a global rule, every session bound to the role for a role rule) for as long as it stays active, so keep it under ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters: one imperative rule in 1-3 sentences, no numbered steps, no examples, no rationale, no restating of defaults. At most three rules compose at once, so this is a standing budget you share. Keep a lane=preference under ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters; there only its short title and description are composed and the content is retrieved on demand, so length is retrieval cost rather than standing prompt cost. Prefer several small entries over one long one, and put procedure in a Document or Skill that the rule references instead of inlining it. ` +
75
- "Under Automatic learning a preference activates immediately; otherwise the receipt returns status=confirmation_required with the exact `humanInput` payload: call `request_human_input` with it verbatim, then call `remember_confirm` with the returned requestId. Mandatory rules always need that confirmation. Do not use this for facts you merely inferred; use knowledge_propose or task notes for those. A confirmed lane=knowledge fact enters the human-reviewed Knowledge claim lifecycle; it does not become workspace memory, so do not expect to find it later through `memory_search`.",
73
+ "Durably remember something the user explicitly asked to keep for this workspace. Route by purpose: lane=knowledge for a fact, decision, incident, bug fix, or outcome that should become searchable Memory; lane=preference creates a Skill for reusable conditional how-to guidance; lane=instruction_policy is only for a universal always/never rule that should apply to nearly every task. " +
74
+ `Write the instruction lane as the shortest complete rule, at most ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters and normally 1-3 imperative sentences, with no numbered steps, examples, rationale, or restated defaults. Keep a Skill under ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters; only its one-sentence descriptor is composed and the full instructions are retrieved on demand. Do not copy one item into multiple lanes. ` +
75
+ "Under Automatic learning a Skill may activate immediately; otherwise the receipt returns status=confirmation_required with the exact `humanInput` payload: call `request_human_input` with it verbatim, then call `remember_confirm` with the returned requestId. Workspace instructions and Memory always need that confirmation. Do not use this for facts you merely inferred; use knowledge_propose or task notes for those. Confirmed lane=knowledge content keeps its reviewed claim provenance and materializes its exact approved text into Memory for later `memory_search` retrieval.",
76
76
  inputSchema: {
77
77
  lane: z.enum(["preference", "instruction_policy", "knowledge"]),
78
78
  ...laneFields,
@@ -130,7 +130,7 @@ export function registerRememberTools(input: RegisterRememberToolsInput): void {
130
130
  "remember_confirm",
131
131
  {
132
132
  description:
133
- "Complete a `remember` that returned status=confirmation_required after the human answered the bound `request_human_input` question. For preference/instruction_policy pass proposalId and learning.receiptId (as decisionReceiptId) from that receipt; for knowledge pass claimId. Always pass the requestId returned by request_human_input. Activation only succeeds when the exact initiating human answered Save on this turn; otherwise the proposal stays for review.",
133
+ "Complete a `remember` that returned status=confirmation_required after the human answered the bound `request_human_input` question. For preference/instruction_policy pass proposalId and learning.receiptId (as decisionReceiptId) from that receipt; for knowledge pass claimId. Always pass the requestId returned by request_human_input. Activation only succeeds when the exact initiating human answered Save on this turn; confirmed knowledge is materialized into searchable Memory from the exact approved text.",
134
134
  inputSchema: {
135
135
  operationId: z.string().uuid(),
136
136
  proposalId: z.string().uuid().optional(),
package/src/mcp/server.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  SESSION_GOAL_SUCCESS_CRITERIA_MAX_BYTES,
39
39
  SESSION_GOAL_TEXT_MAX_BYTES,
40
40
  SESSION_INSTRUCTIONS_MAX_CHARACTERS,
41
+ MAX_SELECTED_VARIABLE_SETS,
41
42
  sessionGoalUtf8Bytes,
42
43
  TASK_NOTE_LIST_DEFAULT_LIMIT,
43
44
  TASK_NOTE_LIST_MAX_LIMIT,
@@ -755,7 +756,7 @@ export function buildOpenGeniMcpServer(
755
756
  "set_session_title",
756
757
  {
757
758
  description:
758
- "Set this session's display title to a concise 3-7 word summary. The title persists across turns: call once on a new untitled session, then only when the topic materially changes. Never call it as routine setup after a continuation, resume, or interruption, or merely to reassert the same title. A human-set title cannot be replaced.",
759
+ "Set this session's display title to a concise 3-7 word topic label. Use a stable noun phrase about the actual task or subject, never a quote/prefix of a prompt, greeting, request boilerplate, URL, identifier, credential, token, or other sensitive value. Call once on a new session, then only when the topic materially changes. Never call it as routine setup after a continuation, resume, or interruption, or merely to reassert the same title. A human-set title cannot be replaced.",
759
760
  inputSchema: { title: z4.string().min(1).max(200) },
760
761
  },
761
762
  async ({ title }) => {
@@ -3256,7 +3257,7 @@ function registerPreferenceRegistryTools(
3256
3257
  "preference_registry_summary",
3257
3258
  {
3258
3259
  description:
3259
- "List bounded deterministic descriptors for organization, workspace, and immutable initiating-human preferences frozen to this exact attempt. Full content is omitted; retrieve only a relevant returned handle.",
3260
+ "List bounded deterministic Skill descriptors for organization, workspace, and the immutable initiating human, frozen to this exact attempt. Full Skill instructions are omitted; retrieve only a relevant returned handle.",
3260
3261
  inputSchema: {},
3261
3262
  },
3262
3263
  async () => json(await getOrCreatePreferenceRegistrySnapshot(deps.db, attemptClaims())),
@@ -3266,7 +3267,7 @@ function registerPreferenceRegistryTools(
3266
3267
  "preference_registry_get",
3267
3268
  {
3268
3269
  description:
3269
- "Retrieve full content for one preference in this exact attempt snapshot. Handles from another account, workspace, human, or attempt are rejected.",
3270
+ "Retrieve the full instructions for one Skill in this exact attempt snapshot. Handles from another account, workspace, human, or attempt are rejected.",
3270
3271
  inputSchema: { retrievalHandle: z4.string().min(1).max(512) },
3271
3272
  },
3272
3273
  async ({ retrievalHandle }) =>
@@ -4295,6 +4296,7 @@ function registerWorkspaceOrchestrationTools(
4295
4296
  tools: z4.array(z4.unknown()).optional(),
4296
4297
  mcpServers: z4.array(z4.unknown()).optional(),
4297
4298
  variableSetId: z4.string().uuid().optional(),
4299
+ variableSetIds: z4.array(z4.string().uuid()).max(MAX_SELECTED_VARIABLE_SETS).optional(),
4298
4300
  environmentId: z4.string().uuid().optional(),
4299
4301
  rigId: z4.string().uuid().optional(),
4300
4302
  model: z4
@@ -4345,6 +4347,26 @@ function registerWorkspaceOrchestrationTools(
4345
4347
  .union([z4.literal("new"), z4.object({ groupId: z4.string().uuid() })])
4346
4348
  .optional(),
4347
4349
  })
4350
+ .superRefine((value, context) => {
4351
+ if (!value.variableSetIds) return;
4352
+ if (new Set(value.variableSetIds).size !== value.variableSetIds.length) {
4353
+ context.addIssue({
4354
+ code: z4.ZodIssueCode.custom,
4355
+ path: ["variableSetIds"],
4356
+ message: "variableSetIds must not contain duplicates",
4357
+ });
4358
+ }
4359
+ const singular = value.variableSetId ?? value.environmentId;
4360
+ if (singular === undefined) return;
4361
+ const expected = value.variableSetIds[value.variableSetIds.length - 1];
4362
+ if (singular !== expected) {
4363
+ context.addIssue({
4364
+ code: z4.ZodIssueCode.custom,
4365
+ path: ["variableSetId"],
4366
+ message: "variableSetId must match the last variableSetIds entry",
4367
+ });
4368
+ }
4369
+ })
4348
4370
  .strict();
4349
4371
  server.registerTool(
4350
4372
  "session_create",
@@ -4355,6 +4377,11 @@ function registerWorkspaceOrchestrationTools(
4355
4377
  },
4356
4378
  async (args) => {
4357
4379
  try {
4380
+ requireVariableSetsUseForMcpAttachments(grant, {
4381
+ variableSetIds: args.variableSetIds,
4382
+ variableSetId: args.variableSetId,
4383
+ environmentId: args.environmentId,
4384
+ });
4358
4385
  if (callerSessionId !== null) {
4359
4386
  await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
4360
4387
  }
@@ -4744,7 +4771,7 @@ function registerWorkspaceOrchestrationTools(
4744
4771
  "set_other_session_title",
4745
4772
  {
4746
4773
  description:
4747
- "Set another session's display title to a concise 3-7 word summary. The target session must belong to this workspace. Replaces an existing title unless a human has manually set it.",
4774
+ "Set another session's display title to a concise 3-7 word topic label. Use a stable noun phrase about the actual task or subject, never a quote/prefix of a prompt, greeting, request boilerplate, URL, identifier, credential, token, or other sensitive value. The target session must belong to this workspace. Replaces an existing automatic title unless a human has manually set it.",
4748
4775
  inputSchema: {
4749
4776
  session_id: z4.string().uuid(),
4750
4777
  title: z4.string().min(1).max(200),
@@ -5342,6 +5369,25 @@ function requireVariableSetsUseForMcpAttachment(
5342
5369
  }
5343
5370
  }
5344
5371
 
5372
+ function requireVariableSetsUseForMcpAttachments(
5373
+ grant: AccessGrant,
5374
+ selection: {
5375
+ variableSetIds?: string[] | undefined;
5376
+ variableSetId?: string | undefined;
5377
+ environmentId?: string | undefined;
5378
+ },
5379
+ ): void {
5380
+ const singular = selection.variableSetId ?? selection.environmentId;
5381
+ const variableSetIds = selection.variableSetIds ?? (singular ? [singular] : undefined);
5382
+ if (variableSetIds === undefined) return;
5383
+ if (!hasPermission(grant.permissions, "variable-sets:attach")) {
5384
+ throw new HTTPException(403, { message: "missing permission: variable-sets:attach" });
5385
+ }
5386
+ if (variableSetIds.length > 0 && !hasPermission(grant.permissions, "variable-sets:use")) {
5387
+ throw new HTTPException(403, { message: "missing permission: variable-sets:use" });
5388
+ }
5389
+ }
5390
+
5345
5391
  /**
5346
5392
  * Project one allowlisted GitHub App repository into the resource an agent or
5347
5393
  * scheduled task attaches. Every listed repository is in the workspace
@@ -534,6 +534,11 @@ export function boundSessionDetailMcp(
534
534
  effectiveControl: unknown = session.effectiveControl,
535
535
  maxBytes = SESSION_DETAIL_MCP_MAX_BYTES,
536
536
  ) {
537
+ const variableSetIds = session.variableSetIds;
538
+ // The plural selection is authoritative. Keep the singular/environment
539
+ // fields only as final-entry compatibility aliases so they cannot be read as
540
+ // complete attachment membership by newer consumers.
541
+ const variableSetId = variableSetIds.at(-1) ?? null;
537
542
  const title = session.title === null ? null : modelStringProjection(session.title, 512);
538
543
  const initialMessage = modelStringProjection(session.initialMessage, 4_000);
539
544
  const instructions =
@@ -592,8 +597,9 @@ export function boundSessionDetailMcp(
592
597
  sandboxGroupId: session.sandboxGroupId,
593
598
  activeSandboxId: session.activeSandboxId,
594
599
  activeEpoch: session.activeEpoch,
595
- variableSetId: session.variableSetId,
596
- environmentId: session.environmentId,
600
+ variableSetIds,
601
+ variableSetId,
602
+ environmentId: variableSetId,
597
603
  rigId: session.rigId,
598
604
  rigVersionId: session.rigVersionId,
599
605
  firstPartyMcpPermissions: permissions.value,
@@ -278,7 +278,7 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
278
278
  });
279
279
  const bytes = new TextEncoder().encode(stableJson(request));
280
280
  return c.json(
281
- await acceptEvent(deps, source, {
281
+ await acceptAutomationEvent(deps, source, {
282
282
  deliveryKey:
283
283
  request.deliveryId ?? `manual:${automationRequestDigest(source.adapterId, bytes)}`,
284
284
  requestDigest: automationRequestDigest(source.adapterId, bytes),
@@ -335,7 +335,7 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
335
335
  }
336
336
  const requestDigest = automationRequestDigest(source.adapterId, rawBody);
337
337
  try {
338
- const result = await acceptEvent(deps, source, {
338
+ const result = await acceptAutomationEvent(deps, source, {
339
339
  deliveryKey: adapter.deliveryKey({
340
340
  headers: c.req.raw.headers,
341
341
  requestDigest,
@@ -357,7 +357,7 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
357
357
  });
358
358
  }
359
359
 
360
- async function acceptEvent(
360
+ export async function acceptAutomationEvent(
361
361
  deps: ApiRouteDeps,
362
362
  source: AutomationSourceSecret,
363
363
  input: {
@@ -7,6 +7,8 @@ import {
7
7
  DocumentAuthorityReclassification,
8
8
  DocumentBase,
9
9
  DocumentDefaultCollectionBackfill,
10
+ DocumentDefaultCollectionBackfillAudit,
11
+ GetDocumentDefaultCollectionBackfillAuditQuery,
10
12
  DocumentSearchRequest,
11
13
  DocumentSearchResponse,
12
14
  FileAsset,
@@ -15,6 +17,9 @@ import {
15
17
  KnowledgeMemorySearchRequest,
16
18
  ListDocumentAuthorityReclassificationsQuery,
17
19
  ListDocumentAuthorityReclassificationsResponse,
20
+ ListDocumentDefaultCollectionBackfillRunsResponse,
21
+ ListDocumentMigrationAuditQuery,
22
+ ListOrganizationDocumentAuthorityReclassificationsResponse,
18
23
  MoveDocumentRequest,
19
24
  ReclassifyDocumentAuthorityRequest,
20
25
  RunDocumentDefaultCollectionBackfillRequest,
@@ -40,10 +45,13 @@ import {
40
45
  getDocument,
41
46
  getDocumentOriginalFile,
42
47
  getDocumentBase,
48
+ getDocumentDefaultCollectionBackfillAudit,
43
49
  listAccessibleDocuments,
44
50
  listDocumentAuthorityReclassifications,
51
+ listDocumentDefaultCollectionBackfillRuns,
45
52
  listDocumentBasesEnsuringDefault,
46
53
  listDocuments,
54
+ listOrganizationDocumentAuthorityReclassifications,
47
55
  moveDocumentToBase,
48
56
  queueDocumentForReindex,
49
57
  reclassifyDocumentAuthority,
@@ -132,6 +140,106 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
132
140
  }
133
141
  });
134
142
 
143
+ app.get("/v1/workspaces/:workspaceId/document-default-collection-backfills", async (c) => {
144
+ const workspaceId = c.req.param("workspaceId");
145
+ const authorization = await requireAccessGrantAuthorization(
146
+ c,
147
+ deps,
148
+ workspaceId,
149
+ "documents:manage",
150
+ );
151
+ if (!hasAccountAdminAuthority(authorization)) {
152
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
153
+ }
154
+ const query = ListDocumentMigrationAuditQuery.safeParse(c.req.query());
155
+ if (!query.success) {
156
+ throw new HTTPException(400, { message: "invalid document migration audit query" });
157
+ }
158
+ try {
159
+ return c.json(
160
+ ListDocumentDefaultCollectionBackfillRunsResponse.parse(
161
+ await listDocumentDefaultCollectionBackfillRuns(db, {
162
+ accountId: authorization.grant.accountId,
163
+ workspaceId,
164
+ actorSubjectId: authorization.grant.subjectId,
165
+ accountAdminAuthorization: requireAccountAdminAuthorizationStamp(authorization),
166
+ ...query.data,
167
+ }),
168
+ ),
169
+ );
170
+ } catch (error) {
171
+ throw documentHttpException(error);
172
+ }
173
+ });
174
+
175
+ app.get("/v1/workspaces/:workspaceId/document-default-collection-backfills/:runId", async (c) => {
176
+ const workspaceId = c.req.param("workspaceId");
177
+ const authorization = await requireAccessGrantAuthorization(
178
+ c,
179
+ deps,
180
+ workspaceId,
181
+ "documents:manage",
182
+ );
183
+ if (!hasAccountAdminAuthority(authorization)) {
184
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
185
+ }
186
+ const runId = RunDocumentDefaultCollectionBackfillRequest.shape.runId.safeParse(
187
+ c.req.param("runId"),
188
+ );
189
+ const query = GetDocumentDefaultCollectionBackfillAuditQuery.safeParse(c.req.query());
190
+ if (!runId.success || !query.success) {
191
+ throw new HTTPException(400, { message: "invalid document migration audit query" });
192
+ }
193
+ try {
194
+ return c.json(
195
+ DocumentDefaultCollectionBackfillAudit.parse(
196
+ await getDocumentDefaultCollectionBackfillAudit(db, {
197
+ accountId: authorization.grant.accountId,
198
+ workspaceId,
199
+ actorSubjectId: authorization.grant.subjectId,
200
+ accountAdminAuthorization: requireAccountAdminAuthorizationStamp(authorization),
201
+ runId: runId.data,
202
+ ...query.data,
203
+ }),
204
+ ),
205
+ );
206
+ } catch (error) {
207
+ throw documentHttpException(error);
208
+ }
209
+ });
210
+
211
+ app.get("/v1/workspaces/:workspaceId/document-authority-reclassifications", async (c) => {
212
+ const workspaceId = c.req.param("workspaceId");
213
+ const authorization = await requireAccessGrantAuthorization(
214
+ c,
215
+ deps,
216
+ workspaceId,
217
+ "documents:manage",
218
+ );
219
+ if (!hasAccountAdminAuthority(authorization)) {
220
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
221
+ }
222
+ const query = ListDocumentMigrationAuditQuery.safeParse(c.req.query());
223
+ if (!query.success) {
224
+ throw new HTTPException(400, { message: "invalid document migration audit query" });
225
+ }
226
+ try {
227
+ return c.json(
228
+ ListOrganizationDocumentAuthorityReclassificationsResponse.parse(
229
+ await listOrganizationDocumentAuthorityReclassifications(db, {
230
+ accountId: authorization.grant.accountId,
231
+ workspaceId,
232
+ actorSubjectId: authorization.grant.subjectId,
233
+ accountAdminAuthorization: requireAccountAdminAuthorizationStamp(authorization),
234
+ ...query.data,
235
+ }),
236
+ ),
237
+ );
238
+ } catch (error) {
239
+ throw documentHttpException(error);
240
+ }
241
+ });
242
+
135
243
  app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
136
244
  const workspaceId = c.req.param("workspaceId");
137
245
  const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "documents:manage");
@@ -486,6 +594,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
486
594
  limit: payload.limit,
487
595
  mode: payload.mode,
488
596
  sourceKinds: payload.sourceKinds,
597
+ authorityKinds: payload.authorityKinds,
489
598
  aclTags: payload.aclTags,
490
599
  initiatingSubjectId: grant.subjectId,
491
600
  surface: "human",
@@ -512,6 +621,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
512
621
  limit: payload.limit,
513
622
  mode: payload.mode,
514
623
  sourceKinds: payload.sourceKinds,
624
+ authorityKinds: payload.authorityKinds,
515
625
  aclTags: payload.aclTags,
516
626
  initiatingSubjectId: grant.subjectId,
517
627
  surface: "human",
@@ -879,13 +989,16 @@ function dropFilename(preferred: string | undefined): string {
879
989
  }
880
990
 
881
991
  function documentHttpException(error: unknown): HTTPException {
882
- const message = error instanceof Error ? error.message : String(error);
992
+ const message = documentDomainErrorMessage(error);
883
993
  if (message.includes("organization document") && message.includes("exact account authority")) {
884
994
  return new HTTPException(403, { message: "missing permission: account:admin" });
885
995
  }
886
996
  if (message.includes("not found")) {
887
997
  return new HTTPException(404, { message });
888
998
  }
999
+ if (message.includes("document Default collection backfill audit run is unavailable")) {
1000
+ return new HTTPException(404, { message });
1001
+ }
889
1002
  if (message.includes("already exists")) {
890
1003
  return new HTTPException(409, { message });
891
1004
  }
@@ -898,7 +1011,9 @@ function documentHttpException(error: unknown): HTTPException {
898
1011
  }
899
1012
  if (
900
1013
  message.includes("reclassification requires") ||
901
- message.includes("backfill requires organization administration")
1014
+ message.includes("backfill requires organization administration") ||
1015
+ message.includes("document migration audit requires organization administration") ||
1016
+ message.includes("document migration audit scope is invalid")
902
1017
  ) {
903
1018
  return new HTTPException(403, { message });
904
1019
  }
@@ -906,7 +1021,12 @@ function documentHttpException(error: unknown): HTTPException {
906
1021
  message.includes("reclassification input is invalid") ||
907
1022
  message.includes("reclassification authority is invalid") ||
908
1023
  message.includes("invalid document authority receipt cursor") ||
909
- message.includes("document authority receipt limit")
1024
+ message.includes("document authority receipt limit") ||
1025
+ message.includes("invalid document migration audit cursor") ||
1026
+ message.includes("document migration audit cursor is invalid") ||
1027
+ message.includes("document migration audit limit") ||
1028
+ message.includes("document migration audit authority is incomplete") ||
1029
+ message.includes("document Default collection backfill audit run id is required")
910
1030
  ) {
911
1031
  return new HTTPException(400, { message });
912
1032
  }
@@ -929,6 +1049,19 @@ function documentHttpException(error: unknown): HTTPException {
929
1049
  return new HTTPException(500, { message });
930
1050
  }
931
1051
 
1052
+ function documentDomainErrorMessage(error: unknown): string {
1053
+ let current: unknown = error;
1054
+ let message = error instanceof Error ? error.message : String(error);
1055
+ const seen = new Set<unknown>();
1056
+ while (current && typeof current === "object" && !seen.has(current) && seen.size < 8) {
1057
+ seen.add(current);
1058
+ const record = current as { cause?: unknown; message?: unknown };
1059
+ if (typeof record.message === "string") message = record.message;
1060
+ current = record.cause;
1061
+ }
1062
+ return message;
1063
+ }
1064
+
932
1065
  function hasAccountAdminAuthority(
933
1066
  authorization: Awaited<ReturnType<typeof requireAccessGrantAuthorization>>,
934
1067
  ): boolean {