-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathapproval.zod.ts
More file actions
279 lines (261 loc) · 13.5 KB
/
Copy pathapproval.zod.ts
File metadata and controls
279 lines (261 loc) · 13.5 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
// 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
]);
/**
* 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.
value: z.string().optional().meta({
description: 'User id / membership tier / position / team / department / field / queue — per `type`',
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',
},
},
}),
/**
* 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>;
/**
* 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' },
}),
/** 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;
}