@frontera-sdk/blueprint 1.52.5 → 1.52.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/blueprint",
3
- "version": "1.52.5",
3
+ "version": "1.52.7",
4
4
  "description": "React hooks for reading Blueprint data and invoking governed Actions from inside a Frontera app.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -56,7 +56,7 @@
56
56
  "smoke": "bun run scripts/smoke.ts"
57
57
  },
58
58
  "dependencies": {
59
- "@frontera-sdk/core": "1.52.5"
59
+ "@frontera-sdk/core": "1.52.7"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "@tanstack/react-query": "^5.90.21",
@@ -1,9 +1,13 @@
1
1
  import type { FronteraClient } from '@frontera-sdk/core/client'
2
2
  import { FronteraError } from '@frontera-sdk/core/errors'
3
3
  import type {
4
+ ActionApprovalDecision,
5
+ ActionDecisionResult,
4
6
  ActionDescriptor,
5
7
  ActionRequest,
6
8
  ActionRequestLifecycle,
9
+ ActionRequestListOptions,
10
+ ActionRequestPage,
7
11
  SubmitActionInput,
8
12
  } from './action-types'
9
13
 
@@ -30,8 +34,14 @@ const APP_USER_TOKEN_PREFIX = 'sk-au-'
30
34
  * reads a request they raised. `submit` is accepted only for records that a
31
35
  * role listing that Action lets the person read. A role that only reads never
32
36
  * widens what they can change. A referenced record need only be readable.
33
- * `requests`, `decide` and `cancel` are for Frontera members and are refused
34
- * here for such a person, before anything is sent.
37
+ *
38
+ * Such a person also decides the approval steps their App role is given:
39
+ * `awaitingMe` lists the requests waiting on one, `decide` approves or
40
+ * rejects one, and `request` reads it. Any other request answers as not
41
+ * found, whether or not it exists. `mine` lists the requests they raised, and
42
+ * `cancel` withdraws one of those while it waits. `requests`, every request
43
+ * in the workspace, is for Frontera members and is refused here for such a
44
+ * person, before anything is sent.
35
45
  */
36
46
  export class ActionClient {
37
47
  constructor(private readonly client: FronteraClient) {}
@@ -98,8 +108,8 @@ export class ActionClient {
98
108
 
99
109
  requests(lifecycle?: readonly ActionRequestLifecycle[]): Promise<ActionRequest[]> {
100
110
  const refused = this.membersOnly(
101
- 'Listing Action requests',
102
- 'Keep the id `submit` returns and read that request with `request(id)` or `useActionRequest`.',
111
+ 'Listing every Action request in the workspace',
112
+ 'Read the requests waiting for their decision with `awaitingMe()` or `useApprovalInbox`, and the requests they raised with `mine()` or `useMyActionRequests`.',
103
113
  )
104
114
  if (refused) return Promise.reject(refused)
105
115
  return this.client.request<ActionRequest[]>(`${BASE}/requests`, {
@@ -107,6 +117,62 @@ export class ActionClient {
107
117
  })
108
118
  }
109
119
 
120
+ /**
121
+ * The waiting requests this person could decide right now, newest first.
122
+ *
123
+ * For a Frontera member: requests waiting on a step workspace members
124
+ * decide, in a workspace where they may approve. For a person signed in to
125
+ * an externally hosted App: requests waiting on a step that names an App
126
+ * role they hold, for a record that role's data scope covers.
127
+ *
128
+ * Never one they raised, one they already decided a step of, or one past
129
+ * its deadline. A request leaves the list as soon as any of that changes, so
130
+ * a list read a moment ago can hold a request that `decide` now refuses.
131
+ * Nothing here says how many other requests are waiting for other people.
132
+ *
133
+ * A page looks at a bounded number of the most recent waiting requests.
134
+ * When it stopped there, the page says `truncated: true`: more may wait
135
+ * behind them.
136
+ */
137
+ awaitingMe(options: ActionRequestListOptions = {}): Promise<ActionRequestPage> {
138
+ return this.page({ awaiting: 'me', ...listQuery(options) })
139
+ }
140
+
141
+ /**
142
+ * The requests this person raised, newest first, in every state unless
143
+ * `lifecycle` narrows it. Each carries its approval steps, who decided them
144
+ * by name and, for a rejected request, the reason.
145
+ *
146
+ * For a person signed in to an externally hosted App: the requests they
147
+ * raised through this App.
148
+ */
149
+ mine(
150
+ options: ActionRequestListOptions & { lifecycle?: readonly ActionRequestLifecycle[] } = {},
151
+ ): Promise<ActionRequestPage> {
152
+ return this.page({
153
+ mine: 'true',
154
+ ...(options.lifecycle?.length ? { lifecycle: options.lifecycle.join(',') } : {}),
155
+ ...listQuery(options),
156
+ })
157
+ }
158
+
159
+ /** One page of a named list: the requests, and where the next page starts. */
160
+ private async page(query: Record<string, string>): Promise<ActionRequestPage> {
161
+ const envelope = await this.client.requestEnvelope<{
162
+ data?: ActionRequest[]
163
+ nextCursor?: string | null
164
+ truncated?: boolean
165
+ } | null>(
166
+ `${BASE}/requests`,
167
+ { query },
168
+ )
169
+ return {
170
+ requests: envelope?.data ?? [],
171
+ nextCursor: envelope?.nextCursor ?? null,
172
+ truncated: envelope?.truncated === true,
173
+ }
174
+ }
175
+
110
176
  request(requestId: string): Promise<ActionRequest> {
111
177
  return this.client.request<ActionRequest>(`${BASE}/requests/${encodeURIComponent(requestId)}`)
112
178
  }
@@ -115,30 +181,52 @@ export class ActionClient {
115
181
  * Approve or reject. Separate from `submit` because it is a different act by
116
182
  * a different person — an Action with separation of duties refuses a decision
117
183
  * from whoever submitted it.
184
+ *
185
+ * `stage` names the approval-chain step being decided, as `approval.currentStage`
186
+ * showed it to the person. It is required for a request approved in steps
187
+ * (one whose `approval` is not `null`): without it the decision is refused as
188
+ * invalid, with reason `ACTION_APPROVAL_STEP_REQUIRED`. If another step is
189
+ * waiting by the time the decision arrives, it is refused with reason
190
+ * `ACTION_APPROVAL_STEP_CHANGED` rather than recorded on that step. Leave it
191
+ * out only for a request that has no approval steps, where naming one is
192
+ * refused.
193
+ *
194
+ * A Frontera member decides the requests and steps that name workspace
195
+ * members. A person signed in to an externally hosted App decides a step
196
+ * that names an App role they hold, for a record that role's data scope
197
+ * covers. To that person every other request answers `ACTION_NOT_FOUND`,
198
+ * the same as a request that does not exist: show "nothing to decide here",
199
+ * not an error about permissions.
200
+ *
201
+ * Resolves to the request as the decision left it, with `request` (the same
202
+ * request) and `approvalDecision` (the decision recorded) beside it: see
203
+ * `ActionDecisionResult`.
118
204
  */
119
- decide(requestId: string, decision: 'approve' | 'reject', reason: string): Promise<ActionRequest> {
120
- const refused = this.membersOnly(
121
- 'Approving or rejecting an Action request',
122
- 'A Frontera member with approval permission decides it in Frontera.',
123
- )
124
- if (refused) return Promise.reject(refused)
125
- return this.client.request<ActionRequest>(
205
+ async decide(
206
+ requestId: string,
207
+ decision: 'approve' | 'reject',
208
+ reason: string,
209
+ stage?: string,
210
+ ): Promise<ActionDecisionResult> {
211
+ const answer = await this.client.request<{ approval: ActionApprovalDecision; request: ActionRequest }>(
126
212
  `${BASE}/requests/${encodeURIComponent(requestId)}/approvals`,
127
- { method: 'POST', body: { decision, reason } },
213
+ { method: 'POST', body: { decision, reason, ...(stage ? { stage } : {}) } },
128
214
  )
215
+ return { ...answer.request, request: answer.request, approvalDecision: answer.approval }
129
216
  }
130
217
 
131
218
  /**
132
- * Cancel takes NO body. It reads the request and the caller from the URL and
133
- * the credential; a `reason` sent here is refused as an invalid command body
134
- * rather than ignored, because the route accepts an exact key set.
219
+ * Cancel sends no body. The platform reads the request from the URL and the
220
+ * caller from the credential, and ignores anything sent as a body: a
221
+ * `reason` sent here would be dropped, not recorded, so none is taken.
222
+ *
223
+ * A Frontera member with permission to cancel requests cancels a request
224
+ * that waits for approval, or is approved and has not started to run. A person signed in to an externally hosted App withdraws
225
+ * only a request they raised, and only while it waits for approval: any
226
+ * other request answers as not found, and one already approved answers
227
+ * `CONFLICT`.
135
228
  */
136
229
  cancel(requestId: string): Promise<ActionRequest> {
137
- const refused = this.membersOnly(
138
- 'Cancelling an Action request',
139
- 'A Frontera member with permission to cancel requests can cancel it in Frontera.',
140
- )
141
- if (refused) return Promise.reject(refused)
142
230
  return this.client.request<ActionRequest>(
143
231
  `${BASE}/requests/${encodeURIComponent(requestId)}/cancel`,
144
232
  { method: 'POST' },
@@ -146,6 +234,15 @@ export class ActionClient {
146
234
  }
147
235
  }
148
236
 
237
+ /** The query a list sends for its filter and position: only what was given. */
238
+ function listQuery(options: ActionRequestListOptions): Record<string, string> {
239
+ return {
240
+ ...(options.actionDefinitionId ? { actionDefinitionId: options.actionDefinitionId } : {}),
241
+ ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
242
+ ...(options.cursor ? { cursor: options.cursor } : {}),
243
+ }
244
+ }
245
+
149
246
  /**
150
247
  * The wire envelope, assembled from the flat input a caller actually has.
151
248
  *
@@ -1,5 +1,6 @@
1
1
  import { createContext, useContext, useEffect } from 'react'
2
2
  import {
3
+ useInfiniteQuery,
3
4
  useMutation,
4
5
  useQuery,
5
6
  useQueryClient,
@@ -13,18 +14,47 @@ import { blueprintKeys } from './hooks'
13
14
  import {
14
15
  actionEffectOf,
15
16
  isTerminalLifecycle,
17
+ type ActionDecisionResult,
16
18
  type ActionDescriptor,
17
19
  type ActionRequest,
18
20
  type ActionRequestLifecycle,
21
+ type ActionRequestPage,
19
22
  type SubmitActionInput,
20
23
  } from './action-types'
21
24
 
25
+ /** Which requests `useApprovalInbox` reads. */
26
+ export interface ApprovalInboxFilter {
27
+ /** Only requests of this Action. */
28
+ actionDefinitionId?: string
29
+ /** Requests read per page: 1 to 200. Left out, 50. */
30
+ pageSize?: number
31
+ }
32
+
33
+ /** Which requests `useMyActionRequests` reads. */
34
+ export interface MyActionRequestsFilter extends ApprovalInboxFilter {
35
+ /** Only requests in these states. Left out, every state. */
36
+ lifecycle?: readonly ActionRequestLifecycle[]
37
+ }
38
+
22
39
  export const actionKeys = {
23
40
  all: ['blueprint', 'actions'] as const,
24
41
  discovery: () => ['blueprint', 'actions', 'discovery'] as const,
25
42
  requests: (lifecycle?: readonly ActionRequestLifecycle[]) =>
26
43
  ['blueprint', 'actions', 'requests', lifecycle?.join(',') ?? 'all'] as const,
27
44
  request: (requestId: string) => ['blueprint', 'actions', 'request', requestId] as const,
45
+ /** With no filter: the prefix every approval inbox is cached under. */
46
+ awaitingMe: (filter?: ApprovalInboxFilter) => (filter
47
+ ? ['blueprint', 'actions', 'awaiting-me', filter.actionDefinitionId ?? 'all', filter.pageSize ?? 'default'] as const
48
+ : ['blueprint', 'actions', 'awaiting-me'] as const),
49
+ /** With no filter: the prefix every list of one's own requests is cached under. */
50
+ mine: (filter?: MyActionRequestsFilter) => (filter
51
+ ? [
52
+ 'blueprint', 'actions', 'mine',
53
+ filter.lifecycle?.join(',') ?? 'all',
54
+ filter.actionDefinitionId ?? 'all',
55
+ filter.pageSize ?? 'default',
56
+ ] as const
57
+ : ['blueprint', 'actions', 'mine'] as const),
28
58
  }
29
59
 
30
60
  export const FronteraActionContext = createContext<ActionClient | null>(null)
@@ -114,6 +144,8 @@ export function useSubmitAction(
114
144
  onSuccess: (request) => {
115
145
  queryClient.setQueryData(actionKeys.request(request.id), request)
116
146
  void queryClient.invalidateQueries({ queryKey: actionKeys.requests() })
147
+ // A request they raised: their own list has one more.
148
+ void queryClient.invalidateQueries({ queryKey: actionKeys.mine() })
117
149
  },
118
150
  })
119
151
  }
@@ -184,6 +216,164 @@ export function useActionRequests(
184
216
  })
185
217
  }
186
218
 
219
+ /** One named list of requests, read page by page. */
220
+ export interface ActionRequestList {
221
+ /** Every request read so far, newest first. */
222
+ requests: ActionRequest[]
223
+ /** True until the first page has arrived. */
224
+ isLoading: boolean
225
+ /** True while any page is being read, the first or a later one. */
226
+ isFetching: boolean
227
+ error: Error | null
228
+ /** Whether the platform has at least one more request past what was read. */
229
+ hasMore: boolean
230
+ /**
231
+ * The approval inbox only: a page stopped at the platform's limit before it
232
+ * had looked at every waiting request, so the list may be incomplete even
233
+ * when `hasMore` is false. Always false for `useMyActionRequests`.
234
+ */
235
+ truncated: boolean
236
+ /** Read the next page. Does nothing when there is none, or one is on its way. */
237
+ loadMore(): Promise<void>
238
+ /** Read the list again from the top. */
239
+ refetch(): Promise<void>
240
+ }
241
+
242
+ type ListReadOptions = {
243
+ /** Set false to read nothing until it is true. */
244
+ enabled?: boolean
245
+ /** Read the list again this often, in milliseconds. Left out, only on `refetch` and after a decision. */
246
+ refetchInterval?: number | false
247
+ }
248
+
249
+ /** A named list as React Query reads it: one page per cursor. */
250
+ export interface ActionRequestListQuery {
251
+ queryKey: readonly unknown[]
252
+ queryFn(context: { pageParam: string | null }): Promise<ActionRequestPage>
253
+ initialPageParam: string | null
254
+ getNextPageParam(last: ActionRequestPage): string | null
255
+ }
256
+
257
+ /**
258
+ * The query behind `useApprovalInbox`: its cache key and how it reads a page.
259
+ * Exported for an App that prefetches or drives the cache itself.
260
+ */
261
+ export function approvalInboxQuery(client: ActionClient, filter: ApprovalInboxFilter = {}): ActionRequestListQuery {
262
+ return {
263
+ queryKey: actionKeys.awaitingMe(filter),
264
+ queryFn: ({ pageParam }) => client.awaitingMe({
265
+ ...(filter.actionDefinitionId ? { actionDefinitionId: filter.actionDefinitionId } : {}),
266
+ ...(filter.pageSize ? { limit: filter.pageSize } : {}),
267
+ ...(pageParam ? { cursor: pageParam } : {}),
268
+ }),
269
+ initialPageParam: null,
270
+ getNextPageParam: (last) => last.nextCursor,
271
+ }
272
+ }
273
+
274
+ /** The query behind `useMyActionRequests`. */
275
+ export function myActionRequestsQuery(client: ActionClient, filter: MyActionRequestsFilter = {}): ActionRequestListQuery {
276
+ return {
277
+ queryKey: actionKeys.mine(filter),
278
+ queryFn: ({ pageParam }) => client.mine({
279
+ ...(filter.lifecycle?.length ? { lifecycle: filter.lifecycle } : {}),
280
+ ...(filter.actionDefinitionId ? { actionDefinitionId: filter.actionDefinitionId } : {}),
281
+ ...(filter.pageSize ? { limit: filter.pageSize } : {}),
282
+ ...(pageParam ? { cursor: pageParam } : {}),
283
+ }),
284
+ initialPageParam: null,
285
+ getNextPageParam: (last) => last.nextCursor,
286
+ }
287
+ }
288
+
289
+ /**
290
+ * The pages read so far as one list. A request that moved between two reads
291
+ * can be on two pages; it is listed once, where it first appears.
292
+ */
293
+ export function requestsOfPages(pages: readonly ActionRequestPage[] | undefined): ActionRequest[] {
294
+ const seen = new Set<string>()
295
+ const requests: ActionRequest[] = []
296
+ for (const page of pages ?? []) {
297
+ for (const request of page.requests) {
298
+ if (seen.has(request.id)) continue
299
+ seen.add(request.id)
300
+ requests.push(request)
301
+ }
302
+ }
303
+ return requests
304
+ }
305
+
306
+ /**
307
+ * Whether any page read so far stopped at the platform's limit: the list may
308
+ * then be incomplete, whatever its last page says about a next one.
309
+ */
310
+ export function pagesTruncated(pages: readonly ActionRequestPage[] | undefined): boolean {
311
+ return (pages ?? []).some((page) => page.truncated)
312
+ }
313
+
314
+ function useRequestList(query: ActionRequestListQuery, options: ListReadOptions): ActionRequestList {
315
+ const result = useInfiniteQuery({
316
+ ...query,
317
+ ...(options.enabled === undefined ? {} : { enabled: options.enabled }),
318
+ ...(options.refetchInterval === undefined ? {} : { refetchInterval: options.refetchInterval }),
319
+ })
320
+ return {
321
+ requests: requestsOfPages(result.data?.pages),
322
+ isLoading: result.isLoading,
323
+ isFetching: result.isFetching,
324
+ error: result.error ?? null,
325
+ hasMore: result.hasNextPage,
326
+ truncated: pagesTruncated(result.data?.pages),
327
+ loadMore: async () => {
328
+ if (result.hasNextPage && !result.isFetchingNextPage) await result.fetchNextPage()
329
+ },
330
+ refetch: async () => {
331
+ await result.refetch()
332
+ },
333
+ }
334
+ }
335
+
336
+ /**
337
+ * What awaits this person's approval: the waiting requests they could decide
338
+ * right now, newest first.
339
+ *
340
+ * Works for a Frontera member and for a person signed in to an externally
341
+ * hosted App, each under their own rules (`ActionClient.awaitingMe`). Render
342
+ * the review screen from `requests`: each carries `intent` (what is asked,
343
+ * and by whom) and `approval` (the steps, and the one waiting). Decide with
344
+ * `useDecideActionRequest`, passing `approval.currentStage` as `stage`; the
345
+ * list is read again after every decision made through that hook.
346
+ *
347
+ * `truncated` is true when a page stopped at the platform's limit of waiting
348
+ * requests looked at: say that the list may be incomplete, and narrow it with
349
+ * `actionDefinitionId`.
350
+ *
351
+ * The list is a moment's answer. Someone else may decide a request, or the
352
+ * record may leave the person's data, between the read and the decision; the
353
+ * decision then answers as it would for a request that is not there. Call
354
+ * `refetch`, or set `refetchInterval`, to keep a screen left open current.
355
+ */
356
+ export function useApprovalInbox(options: ApprovalInboxFilter & ListReadOptions = {}): ActionRequestList {
357
+ const client = useActionClient()
358
+ const { enabled, refetchInterval, ...filter } = options
359
+ return useRequestList(approvalInboxQuery(client, filter), { enabled, refetchInterval })
360
+ }
361
+
362
+ /**
363
+ * The requests this person raised, newest first: what became of each, the
364
+ * approval steps it passed, who decided them by name and, for a rejected
365
+ * request, the reason given.
366
+ *
367
+ * For a person signed in to an externally hosted App: the requests they
368
+ * raised through this App. Read again after `useSubmitAction`,
369
+ * `useDecideActionRequest` and `useCancelActionRequest` succeed.
370
+ */
371
+ export function useMyActionRequests(options: MyActionRequestsFilter & ListReadOptions = {}): ActionRequestList {
372
+ const client = useActionClient()
373
+ const { enabled, refetchInterval, ...filter } = options
374
+ return useRequestList(myActionRequestsQuery(client, filter), { enabled, refetchInterval })
375
+ }
376
+
187
377
  /**
188
378
  * Approve or reject a Request awaiting a decision.
189
379
  *
@@ -191,20 +381,65 @@ export function useActionRequests(
191
381
  * submitted it, so this will fail for the requester — correctly. Surface that
192
382
  * refusal rather than hiding the control: "someone else must approve this" is
193
383
  * the information the user needs.
384
+ *
385
+ * Works with the session of a person signed in to an externally hosted App,
386
+ * for a step their App role decides.
387
+ *
388
+ * For a request approved in steps, `stage` is required: pass
389
+ * `request.approval.currentStage` from the request the person is looking at,
390
+ * so a decision is never recorded on a step they did not read. The hook does
391
+ * not fill it in from its cache: the cache may have been read again since the
392
+ * screen was drawn, and only your component knows which step it showed. A
393
+ * decision without it is refused (`ACTION_APPROVAL_STEP_REQUIRED`). Leave it
394
+ * out for a request whose `approval` is `null`.
395
+ *
396
+ * Resolves to the request as the decision left it (`ActionDecisionResult`).
397
+ * The request, the approval inbox and the lists of requests are read again.
194
398
  */
195
399
  export function useDecideActionRequest(): UseMutationResult<
196
- ActionRequest,
400
+ ActionDecisionResult,
197
401
  Error,
198
- { requestId: string; decision: 'approve' | 'reject'; reason: string }
402
+ { requestId: string; decision: 'approve' | 'reject'; reason: string; stage?: string }
199
403
  > {
200
404
  const client = useActionClient()
201
405
  const queryClient = useQueryClient()
202
406
 
203
407
  return useMutation({
204
- mutationFn: ({ requestId, decision, reason }) => client.decide(requestId, decision, reason),
205
- onSuccess: (request) => {
206
- queryClient.setQueryData(actionKeys.request(request.id), request)
207
- void queryClient.invalidateQueries({ queryKey: actionKeys.requests() })
208
- },
408
+ mutationFn: ({ requestId, decision, reason, stage }) => client.decide(requestId, decision, reason, stage),
409
+ onSuccess: (decided) => refreshAfterWrite(queryClient, decided.id),
410
+ })
411
+ }
412
+
413
+ /**
414
+ * Withdraw a Request that is still waiting.
415
+ *
416
+ * A person signed in to an externally hosted App withdraws only a request
417
+ * they raised, while it waits for approval. A Frontera member needs the
418
+ * permission to cancel requests. The request and the lists are read again.
419
+ */
420
+ export function useCancelActionRequest(): UseMutationResult<ActionRequest, Error, string> {
421
+ const client = useActionClient()
422
+ const queryClient = useQueryClient()
423
+
424
+ return useMutation({
425
+ mutationFn: (requestId) => client.cancel(requestId),
426
+ onSuccess: (cancelled) => refreshAfterWrite(queryClient, cancelled.id),
209
427
  })
210
428
  }
429
+
430
+ /**
431
+ * After a decision or a cancellation: read the request again rather than
432
+ * cache the reply, which carries no `intent` and no approval steps, and read
433
+ * every list it may have left or changed in.
434
+ */
435
+ export function refreshAfterWrite(
436
+ queryClient: { invalidateQueries(filters: { queryKey: readonly unknown[] }): Promise<void> },
437
+ requestId: string,
438
+ ): void {
439
+ for (const queryKey of [
440
+ actionKeys.request(requestId),
441
+ actionKeys.requests(),
442
+ actionKeys.awaitingMe(),
443
+ actionKeys.mine(),
444
+ ]) void queryClient.invalidateQueries({ queryKey })
445
+ }
@@ -111,12 +111,49 @@ export function recordVersionOf(instance: unknown): number | undefined {
111
111
  return typeof version === 'number' ? version : undefined
112
112
  }
113
113
 
114
+ /**
115
+ * What a submission of this Action waits for.
116
+ *
117
+ * Any mode but `'none'` means the request may wait for approval. Test
118
+ * `mode !== 'none'`, not `mode === 'required'`: a mode you did not test for
119
+ * would otherwise read as "no approval". Under `'chain'` the request passes
120
+ * its steps one after another, and a step whose condition does not hold for
121
+ * it is skipped; read the request's `approval` to see where it stands.
122
+ */
114
123
  export interface ActionApprovalPolicy {
115
- mode: 'none' | 'required'
124
+ mode: 'none' | 'required' | 'conditional' | 'chain'
116
125
  threshold?: number
117
- separationOfDuties?: boolean
126
+ separationOfDuties?: boolean | 'strict'
127
+ /** Under `'chain'`: how long a request may wait for its steps, in hours or days, such as `24h` or `3d`. */
128
+ expiresAfter?: string
129
+ /** Under `'chain'`: the steps, in order. Absent for every other mode. */
130
+ stages?: ActionApprovalChainStep[]
131
+ }
132
+
133
+ /**
134
+ * One step of an Action's approval chain, as discovery describes it.
135
+ *
136
+ * `conditional` says whether the step applies only to some requests. The
137
+ * condition itself is not told: read a request's `approval` to see which
138
+ * steps applied to it.
139
+ *
140
+ * A step whose `approver` is an App role is decided for requests raised
141
+ * through an App that has that role. When such a step is not `conditional`,
142
+ * every request for the Action must be raised through such an App: one raised
143
+ * with no App is refused when it is submitted.
144
+ */
145
+ export interface ActionApprovalChainStep {
146
+ key: string
147
+ /** Workspace members who may approve Action Requests, or the people who hold the named App role. */
148
+ approver: 'members' | { role: string }
149
+ /** How many approvals the step needs. */
150
+ quorum: number
151
+ conditional: boolean
118
152
  }
119
153
 
154
+ /** Who decides a step of a request's approval chain: workspace members who may approve, or the people who hold an App role. */
155
+ export type ActionApproverRule = { members: true } | { role: string; scope: 'subject' }
156
+
120
157
  /**
121
158
  * One Action this caller may invoke.
122
159
  *
@@ -161,6 +198,11 @@ export interface ActionDescriptor {
161
198
  * `appUserName` when it is present; it can be `null` when the person's identity
162
199
  * provider sent no name. Both are `null` for every other request, and absent
163
200
  * from a platform that predates the fields.
201
+ *
202
+ * A reader who is signed in to an externally hosted App is given names and no
203
+ * identifier of anyone else: on a request someone else raised, `userId`,
204
+ * `agentId`, `appUserId` and `applicationId` are all `null`. On a request they
205
+ * raised themselves, `appUserId` and `applicationId` are their own.
164
206
  */
165
207
  export interface ActionRequestRequester {
166
208
  kind: 'agent' | 'person' | 'automation'
@@ -240,6 +282,117 @@ export interface ActionRequest {
240
282
  * Never fall back to a guess.
241
283
  */
242
284
  intent?: ActionRequestIntent | null
285
+ /**
286
+ * Where the request stands in its Action's approval chain. Present on reads
287
+ * of a request whose Action uses approval mode `chain`; `null` for every
288
+ * other request, and absent from a platform that predates the field.
289
+ */
290
+ approval?: ActionRequestApproval | null
291
+ }
292
+
293
+ /** One step of a request's approval chain. */
294
+ export interface ActionRequestApprovalStep {
295
+ /** The step's key, as the Action's approval chain names it. */
296
+ key: string
297
+ /**
298
+ * `open` is the step waiting for decisions now; `pending` waits for the
299
+ * steps before it. `skipped` never applied to this request: its condition
300
+ * did not hold.
301
+ */
302
+ status: 'pending' | 'open' | 'approved' | 'skipped' | 'rejected'
303
+ /**
304
+ * Who decides the step: workspace members who may approve Action Requests,
305
+ * or the people who hold the named role in the App the request was raised
306
+ * through.
307
+ */
308
+ approverRule: ActionApproverRule
309
+ /** How many approvals the step needs. */
310
+ quorum: number
311
+ /**
312
+ * Who decided this step, in the order they decided, by display name.
313
+ * `name` is `null` for someone no longer a member of the organization, and
314
+ * for a person whose App sign-in carried no name.
315
+ */
316
+ decidedBy: Array<{ name: string | null }>
317
+ /** When the step was approved or rejected; `null` while it is not settled. */
318
+ decidedAt: string | null
319
+ }
320
+
321
+ /**
322
+ * A request's approval chain: every step, the one waiting now, and the
323
+ * rejection that closed the request, if one did.
324
+ */
325
+ export interface ActionRequestApproval {
326
+ stages: ActionRequestApprovalStep[]
327
+ /** The key of the step waiting for decisions; `null` when none is. */
328
+ currentStage: string | null
329
+ /**
330
+ * The rejection that closed the request. `reason` is what the approver
331
+ * wrote; it is `null` when the stored reason could not be read.
332
+ */
333
+ rejection: { stage: string; reason: string | null; decidedBy: { name: string | null } } | null
334
+ }
335
+
336
+ /**
337
+ * One page of a request list. `nextCursor` is `null` on the last page;
338
+ * otherwise pass it back as `cursor` to read the next one.
339
+ *
340
+ * `truncated` is only ever true for `awaitingMe()`: the platform looks at a
341
+ * bounded number of the most recent waiting requests for one page, and it
342
+ * stopped there. Requests the person could decide may wait behind them, so a
343
+ * short or empty page with no `nextCursor` is not proof that nothing else
344
+ * waits. Tell the person the list may be incomplete, and narrow it with
345
+ * `actionDefinitionId`.
346
+ */
347
+ export interface ActionRequestPage {
348
+ requests: ActionRequest[]
349
+ nextCursor: string | null
350
+ truncated: boolean
351
+ }
352
+
353
+ /** Which requests a list reads, and where in it. */
354
+ export interface ActionRequestListOptions {
355
+ /** Only requests of this Action. */
356
+ actionDefinitionId?: string
357
+ /** Requests per page: 1 to 200. Left out, 50. */
358
+ limit?: number
359
+ /** `nextCursor` of the page before. */
360
+ cursor?: string
361
+ }
362
+
363
+ /** A decision as the platform recorded it. */
364
+ export interface ActionApprovalDecision {
365
+ id: string
366
+ requestId: string
367
+ decision: 'approve' | 'reject'
368
+ /** The approval-chain step decided; `null` for an Action that does not use a chain. */
369
+ stage: string | null
370
+ decidedAt: string
371
+ expiresAt?: string
372
+ policyThreshold?: number
373
+ separationOfDuties?: boolean
374
+ humanApproverId?: string
375
+ humanApproverRole?: string
376
+ }
377
+
378
+ /**
379
+ * What `decide` resolves to: the request as the decision left it, with the
380
+ * two parts the platform answers a decision with.
381
+ *
382
+ * It IS the request (`id`, `lifecycle` and the rest read as they do on any
383
+ * `ActionRequest`), so code written against the earlier `ActionRequest`
384
+ * return type keeps compiling and now reads real values. `request` is that
385
+ * same request under the name the platform gives it, and `approvalDecision`
386
+ * is the decision just recorded. The platform calls that part `approval`; it
387
+ * has another name here because `approval` on a request already means where
388
+ * the request stands in its approval chain.
389
+ *
390
+ * Like every reply to a write, it carries no `intent` and no chain progress:
391
+ * read the request again for those.
392
+ */
393
+ export interface ActionDecisionResult extends ActionRequest {
394
+ request: ActionRequest
395
+ approvalDecision: ActionApprovalDecision
243
396
  }
244
397
 
245
398
  /**