-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathapproval.zod.ts
More file actions
401 lines (379 loc) · 20.2 KB
/
Copy pathapproval.zod.ts
File metadata and controls
401 lines (379 loc) · 20.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
import { lazySchema } from '../shared/lazy-schema';
/**
* Approval Step Approver Type
*/
export const ApproverType = z.enum([
'user', // Specific user(s)
// The better-auth ORG-MEMBERSHIP TIER (sys_member.role: owner / admin /
// member), spelled with the projection name ADR-0057 D7 mandates and
// ADR-0090 D3 assumes ("relabelled `org_membership_level` … its UI label is
// 'organization membership', never 'role'"). NOT an org position: a value
// like 'sales_manager' matches nobody — author `position` for those.
'org_membership_level',
// @deprecated ADR-0090 D3 — the pre-relabel spelling of
// `org_membership_level`. D3 makes "role" reserved-forbidden on platform
// surfaces; its exception covers better-auth's own `sys_member.role` column,
// NOT this enum (which is ours). Accepted for one deprecation window: the
// runtime resolves it identically and warns, `os lint` prescribes the
// rewrite. Removed in the next major.
'role',
'position', // Holders of a position (sys_user_position, ADR-0090 D3)
'team', // Members of a flat collaboration team (sys_team)
'department', // Members of a department + all descendant departments (sys_business_unit)
'manager', // Submitter's manager (sys_user.manager_id)
'field', // User ID defined in a record field
'queue', // Data ownership queue
/**
* #3447 P2: a CEL expression resolved AT NODE ENTRY against three explicit
* roots — `current.*` (the record's live state), `trigger.*` (the submit-time
* snapshot) and `vars.*` (flow variables, incl. upstream node outputs). The
* result (a user-id string, CSV, or string array — or intermediate ids
* re-expanded per `resolveAs`) becomes the approver slate. `record` and bare
* field names are deliberately NOT available: `record` means "the record at
* event time" everywhere else on the platform (flow conditions, hooks), and
* reusing it here would silently alias one of the two times.
*/
'expression'
]);
/**
* Deprecated approver-type spellings → their canonical replacement
* (ADR-0090 D3). The runtime and `os lint` both read this map, so a future
* removal is a one-line edit here plus the enum entry.
*/
export const DEPRECATED_APPROVER_TYPES = {
role: 'org_membership_level',
} as const satisfies Record<string, z.infer<typeof ApproverType>>;
/** Resolve a possibly-deprecated approver type to its canonical spelling. */
export function canonicalApproverType(type: string): string {
return (DEPRECATED_APPROVER_TYPES as Record<string, string>)[type] ?? type;
}
// ==========================================================================
// Approval as a Flow Node (ADR-0019, canonical)
// ==========================================================================
//
// ADR-0019 collapsed the standalone approval *authoring* type into Flow. An
// approval is now authored as a flow with one or more **Approval nodes**
// (`type: 'approval'`); the engine rides the node's durable pause. The former
// process-level concepts re-home as:
// - `steps` → successive Approval nodes on the canvas
// - `entryCriteria` → the condition on the edge entering the node
// - `onApprove`/`onReject` → the nodes wired to the node's `approve`/`reject` edges
// - `rejectionBehavior: back_to_previous` → a back-edge to an earlier node
// - `lockRecord` / `approvalStatusField` / `escalation` / `behavior` / approvers
// → {@link ApprovalNodeConfigSchema} node config
// The process-driven schemas (ApprovalProcessSchema / ApprovalStepSchema /
// ApprovalActionSchema) were removed in ADR-0019 P4.
/**
* Registry node type for the Approval node. The `plugin-approvals` package
* registers an executor under this type (ADR-0018), so an approval rides the
* one flow engine as a durable-pause node rather than a second engine.
*/
export const APPROVAL_NODE_TYPE = 'approval' as const;
/**
* Canonical decisions an Approval node emits. The engine selects the
* downstream branch by matching these against out-edge `label`s
* (see {@link ApprovalNodeConfigSchema}).
*/
export const ApprovalDecision = z.enum(['approve', 'reject']);
export type ApprovalDecision = z.infer<typeof ApprovalDecision>;
/**
* Edge labels an Approval node's out-edges use to declare which branch a
* decision follows. `resume(runId, { branchLabel })` passes the matching
* label so the engine continues down the right edge.
*/
export const APPROVAL_BRANCH_LABELS = {
approve: 'approve',
reject: 'reject',
/**
* ADR-0044 send-back-for-revision: the request finalizes `returned` and the
* flow walks this edge to a wait point where the submitter reworks the
* record; a later resubmit re-enters the approval node via a declared
* back-edge (round N+1).
*/
revise: 'revise',
/**
* ADR-0044: informational label a resubmit resume passes so the wait node's
* out-edge selection is explicit when authors label the back-edge.
*/
resubmit: 'resubmit',
} as const;
/** A single approver assignment on an Approval node. */
export const ApprovalNodeApproverSchema = lazySchema(() => z.object({
// `xEnumDeprecated` lists enum members that still PARSE but must not be
// offered for new authoring. Without it the Studio designer derives its
// approver-type dropdown straight from this enum and keeps offering `role`
// — the exact trap ADR-0090 D3 is retiring — one click away from `position`.
// Renderers omit these from pickers while still rendering a stored value.
type: ApproverType.meta({
xEnumDeprecated: Object.keys(DEPRECATED_APPROVER_TYPES),
}),
/**
* The approver reference, interpreted per `type`: a user id (`user`), a
* membership tier — owner/admin/member (`org_membership_level`), a position
* machine name (`position`), team/department id (`team`/`department`), field
* name holding a user id (`field`), or queue id (`queue`). Omitted for
* `manager` (resolved from the submitter's `manager_id`).
*/
// `xRef` marks this string as a *polymorphic* typed reference (ADR-0018
// §configSchema): the concrete picker follows the sibling `type` column, so
// the Studio designer shows a user/membership-tier/position/team/department/
// queue picker — or an object-field picker (resolved from the flow's
// `$trigger` object) when `type` is `field`. `manager` and any unmapped
// value carry no `value` and stay free text. A single `.meta()` carries both
// description and annotation.
//
// The `role` → `org-membership-level` picker kind is the deprecated alias's
// entry: it maps to the SAME picker as the canonical spelling, so a stored
// legacy node still renders correctly for its deprecation window.
// `expression` is intentionally ABSENT from the map: an unmapped type keeps
// the value as free text, so the designer renders a plain input for the CEL
// source until a dedicated expression editor lands (objectui follow-up).
value: z.string().optional().meta({
description: 'User id / membership tier / position / team / department / field / queue — per `type`; '
+ 'for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*`',
xRef: {
kindFrom: 'type',
objectSource: '$trigger',
map: {
user: 'user',
org_membership_level: 'org-membership-level',
role: 'org-membership-level',
position: 'position',
team: 'team',
department: 'department',
field: 'object-field',
queue: 'queue',
},
},
}),
/**
* #3447 P2, `expression` approvers only: how the expression's resolved values
* are turned into people. `user` (default) treats each value as a user id.
* `department` / `position` / `team` treat each value as that kind of id and
* expand it through the same graph lookups the static approver types use —
* e.g. an expression yielding department ids + `resolveAs: 'department'`
* fans out into every member of every returned department. With
* `behavior: 'per_group'`, each intermediate value forms its own group (one
* sign-off per returned department), keyed by that value.
*/
resolveAs: z.enum(['user', 'department', 'position', 'team']).optional()
.describe("How an `expression` result is expanded into approvers (default 'user')"),
/**
* Optional group label (#3266). With `behavior: 'per_group'`, approvers that
* share a label form one group and the node advances only once EACH group has
* `minApprovals` approvals — e.g. one legal AND one finance sign-off. Ignored
* by other behaviors. Approvers without a label each form their own group
* (keyed by position), so a plain per-approver list still behaves sensibly.
*/
group: z.string().optional().describe('Group label for per_group sign-off (e.g. "legal", "finance")'),
}));
export type ApprovalNodeApprover = z.infer<typeof ApprovalNodeApproverSchema>;
/**
* A TYPED decision-output declaration (#3447 P2 follow-up). The bare-string
* form of a `decisionOutputs` entry renders as free text; this form tells the
* decision UI which picker to render and whether to collect one id or many.
* The runtime treats `key` as the whitelist entry either way — `type` and
* `multiple` only shape the INPUT WIDGET, never the accepted value.
*/
export const DecisionOutputDefSchema = lazySchema(() => z.object({
/** The output key — what the flow receives as `<nodeId>.<key>`. */
key: z.string().min(1).describe('Output key (the flow variable name under the node id)'),
/** Display label for the decision-dialog field; defaults to a title-cased key. */
label: z.string().optional().describe('Field label in the decision dialog'),
/**
* Input widget: `text` (default — free text), or a record picker —
* `user` (sys_user), `department` (sys_business_unit), `position`
* (sys_position), `team` (sys_team). Picker values are record ids.
*/
type: z.enum(['text', 'user', 'department', 'position', 'team']).optional()
.describe("Decision-dialog input widget (default 'text')"),
/** Collect an id array instead of a single value (multi-select picker). */
multiple: z.boolean().optional().describe('Collect multiple values (id array)'),
}));
export type DecisionOutputDef = z.infer<typeof DecisionOutputDefSchema>;
/**
* Normalize a `decisionOutputs` array (bare keys and typed declarations mixed)
* into the typed form. The ONE place both sides read the union: the runtime
* whitelists `normalizeDecisionOutputs(...).map(d => d.key)`, the request read
* surfaces the normalized defs for the decision UI — so a bare key and
* `{ key }` can never behave differently.
*/
export function normalizeDecisionOutputs(
declared: unknown,
): DecisionOutputDef[] {
if (!Array.isArray(declared)) return [];
const out: DecisionOutputDef[] = [];
for (const entry of declared) {
if (typeof entry === 'string') {
if (entry) out.push({ key: entry });
} else if (entry && typeof entry === 'object' && typeof (entry as any).key === 'string' && (entry as any).key) {
const e = entry as Record<string, unknown>;
out.push({
key: String(e.key),
...(typeof e.label === 'string' && e.label ? { label: e.label } : {}),
...(typeof e.type === 'string' && e.type !== 'text' ? { type: e.type as DecisionOutputDef['type'] } : {}),
...(e.multiple === true ? { multiple: true } : {}),
});
}
}
return out;
}
/**
* Per-node SLA escalation — carried on the Approval node itself, so each
* Approval step on the canvas defines its own SLA.
*/
export const ApprovalEscalationSchema = lazySchema(() => z.object({
enabled: z.boolean().default(false).describe('Enable SLA-based escalation for this node'),
timeoutHours: z.number().min(1).describe('Hours before escalation triggers'),
action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify')
.describe('Action on escalation timeout'),
// Escalation hands the request to a position (the common case — e.g. an
// approvals supervisor); the Studio designer renders a position picker, but
// free text is still accepted for a specific user id. The engine expands a
// position machine name to its holders via `sys_user_position` (ADR-0090
// D3) and falls back to treating the value as a user id when nobody holds
// it. NOT a better-auth membership tier — same contract as ApproverType.
escalateTo: z.string().optional().meta({
description: 'User id or position machine name to escalate to',
xRef: { kind: 'position' },
}),
notifySubmitter: z.boolean().default(true).describe('Notify the original submitter on escalation'),
}));
export type ApprovalEscalation = z.infer<typeof ApprovalEscalationSchema>;
/**
* Config for an **Approval node** (`type: 'approval'`) on a flow — the ADR-0019
* replacement for an {@link ApprovalStepSchema}. The node opens an approval
* request on entry, suspends the run, and resumes down its `approve` / `reject`
* out-edge once a decision is recorded.
*
* What does NOT live here (re-homed to the flow graph, by design):
* - **entry criteria** → the condition on the edge entering this node
* - **on-approve / on-reject actions** → the nodes wired to the
* `approve` / `reject` out-edges
* - **back-to-previous rejection** → a back-edge to an earlier node
*
* Approval *state* (request/action rows, record lock, status mirror) remains
* first-class engine-adjacent state owned by `plugin-approvals`; this config
* only describes how the node behaves.
*/
export const ApprovalNodeConfigSchema = lazySchema(() => z.object({
/** Who may act on this step. */
approvers: z.array(ApprovalNodeApproverSchema).min(1).describe('Allowed approvers for this node'),
/**
* How multiple approvers combine (#3266):
* - `first_response` — any one approval (or rejection) finalizes the node.
* - `unanimous` — every resolved approver must approve.
* - `quorum` — `minApprovals` of N approvals finalize (M-of-N collective sign-off).
* - `per_group` — each `group` (see approver `group`) must reach `minApprovals`
* approvals (default 1), i.e. one-from-each-group sign-off (会签).
* In every mode a single rejection finalizes the node as `rejected` (one veto).
* Weighted voting and approval-matrix governance are enterprise
* (objectstack-ai/cloud#861), not here.
*/
behavior: z.enum(['first_response', 'unanimous', 'quorum', 'per_group']).default('first_response')
.describe('How to combine multiple approvers'),
/**
* Threshold for `quorum` (total approvals required, M of N) and `per_group`
* (approvals required from EACH group). Defaults to 1. Clamped at runtime so
* it can never exceed the resolvable approver count (no deadlock).
*/
minApprovals: z.number().int().min(1).optional()
.describe('Approvals required — total (quorum) or per group (per_group). Default 1'),
/** Lock the triggering record from edits while this node is pending. */
lockRecord: z.boolean().default(true).describe('Lock the record from editing while pending'),
/**
* Field on the business object to mirror the request status onto
* (`pending`/`approved`/`rejected`/`recalled`). Should be readonly on the
* object. Omitted ⇒ status is exposed only via `sys_approval_request`.
*/
approvalStatusField: z.string().optional()
// `xRef` marks this string as a typed reference (ADR-0018 §configSchema):
// the Studio designer renders an object-field picker instead of free text.
// `objectSource: '$trigger'` resolves the field catalog from the flow's
// trigger object (the record this approval acts on). A single `.meta()`
// carries both description and the annotation so neither is dropped.
.meta({
description: 'Business-object field to mirror request status onto',
xRef: { kind: 'object-field', objectSource: '$trigger' },
}),
/**
* #3447 P2: what happens when approver resolution yields NO concrete person
* at node entry (an empty expression result, an unstaffed position, an empty
* multi-select field, …):
* - `admin_rescue` (default) — open the request anyway and warn loudly; a
* privileged admin can take it over via Reassign (#3424). The only option
* that neither waves the record through nor kills the run — approval's job
* is to gatekeep, so an empty slate must never silently pass.
* - `fail` — the node fails (fault edge / run failure). Choose when an empty
* slate can only mean a configuration bug.
* - `auto_approve` — skip the request and continue down the `approve` edge
* with `output.autoApproved = true`. The DingTalk/Feishu default; opt-in
* here because it silently waves the record through.
*/
onEmptyApprovers: z.enum(['admin_rescue', 'fail', 'auto_approve']).default('admin_rescue')
.describe('Behavior when no concrete approver resolves at node entry'),
/**
* #3447 P2: keys a decision may carry as structured outputs
* (`decide(..., { outputs })`). The AUTHOR declares the keys; approvers only
* fill values — the same trust model as a `screen` node's author-defined
* fields. Accepted outputs resume the run as `<nodeId>.<key>` flow variables
* (never bare names, so an approver can't shadow author variables), where a
* later node's `expression` approver can read them
* (`vars.<nodeId>.picked_departments`) — "the previous approver picks the
* next step's approvers" without a record-field detour. A decision carrying
* undeclared keys is rejected; `decision` / `requestId` are reserved.
*
* An entry is either a bare key (a plain text input in the decision UI) or a
* TYPED declaration — `{ key, type, multiple }` — that tells the decision UI
* to render a record picker instead of free text: `user` → a sys_user
* picker, `department`/`position`/`team` → the matching system-object
* picker. `multiple: true` collects an id ARRAY (the natural fan-out shape
* for an `expression` approver downstream). The type describes the INPUT
* WIDGET only — the runtime accepts whatever value shape arrives (single id,
* id[], CSV) and hands it to the flow verbatim.
*/
decisionOutputs: z.array(z.union([
z.string(),
DecisionOutputDefSchema,
])).optional()
.describe('Author-declared decision outputs — bare keys or typed { key, type, multiple } declarations'),
/** Optional per-node SLA escalation. */
escalation: ApprovalEscalationSchema.optional().describe('Per-node SLA escalation'),
/**
* ADR-0044: maximum send-backs-for-revision per (run, node). A send-back
* that would exceed the budget auto-rejects instead (the run resumes down
* the `reject` edge with `output.autoRejected = true`), so instances cannot
* orbit the revise loop forever. `0` disables send-back (always
* auto-rejects). Only meaningful when the node has a `revise` out-edge.
*/
maxRevisions: z.number().int().min(0).default(3)
.describe('Max send-backs for revision before auto-reject (0 = send-back disabled)'),
}));
export type ApprovalNodeConfig = z.infer<typeof ApprovalNodeConfigSchema>;
/**
* JSON Schema for {@link ApprovalNodeConfigSchema}, memoized.
*
* Published on the Approval action descriptor's `configSchema`
* (ADR-0018/0019) so the **engine** is the single source of truth for the
* node's config contract: the Studio flow designer renders the Approval node's
* property form from this schema (rather than a hardcoded client form), and the
* same schema backs `registerFlow()` config validation. Derived with Zod v4's
* `z.toJSONSchema` in `input` mode (the author-facing shape — default-bearing
* fields are optional). Lazily computed so the wrapped schema is only resolved
* when a descriptor is actually built.
*/
let cachedApprovalNodeConfigJsonSchema: unknown;
export function getApprovalNodeConfigJsonSchema(): unknown {
if (cachedApprovalNodeConfigJsonSchema === undefined) {
cachedApprovalNodeConfigJsonSchema = z.toJSONSchema(ApprovalNodeConfigSchema, {
target: 'draft-2020-12',
io: 'input',
// Approval config has no unrepresentable constructs today; keep the
// designer resilient if one is ever added rather than throwing at boot.
unrepresentable: 'any',
});
}
return cachedApprovalNodeConfigJsonSchema;
}