@objectstack/plugin-approvals 15.1.1 → 16.0.0-rc.1

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.
@@ -5,6 +5,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js';
5
5
  import { SysApprovalAction } from './sys-approval-action.object.js';
6
6
  import { SysApprovalApprover } from './sys-approval-approver.object.js';
7
7
  import { SysApprovalToken } from './sys-approval-token.object.js';
8
+ import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
8
9
  import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
9
10
  import {
10
11
  ApprovalService,
@@ -12,7 +13,7 @@ import {
12
13
  ESCALATION_SCAN_INTERVAL_MS,
13
14
  type ApprovalEngine,
14
15
  } from './approval-service.js';
15
- import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js';
16
+ import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
16
17
  import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
17
18
 
18
19
  export interface ApprovalsPluginOptions {
@@ -70,7 +71,7 @@ export class ApprovalsServicePlugin implements Plugin {
70
71
  scope: 'system',
71
72
  defaultDatasource: 'cloud',
72
73
  namespace: 'sys',
73
- objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
74
+ objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
74
75
  // ADR-0029 D7 — contribute the Approvals entries into the Setup app's
75
76
  // `group_approvals` slot. This plugin owns these objects (K2.b), so it
76
77
  // ships their menu too; when the plugin isn't installed the slot is empty.
@@ -82,6 +83,7 @@ export class ApprovalsServicePlugin implements Plugin {
82
83
  items: [
83
84
  { id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
84
85
  { id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' },
86
+ { id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' },
85
87
  ],
86
88
  },
87
89
  ],
@@ -122,12 +124,16 @@ export class ApprovalsServicePlugin implements Plugin {
122
124
  });
123
125
 
124
126
  // Record lock: block edits to a record while it has a pending request.
127
+ // Delegation write-guard: a self-service OOO delegation may only name the
128
+ // acting user as delegator (#1322 follow-up). Both bind under the same
129
+ // package id, so unbindAllHooks clears them together.
125
130
  if (!this.options.disableAutoHooks) {
126
131
  try {
127
132
  unbindAllHooks(engine);
128
133
  bindApprovalLockHook(engine, ctx.logger);
134
+ bindDelegationWriteGuard(engine, ctx.logger);
129
135
  } catch (err: any) {
130
- ctx.logger.warn?.('[approvals] failed to bind record-lock hook', { error: err?.message });
136
+ ctx.logger.warn?.('[approvals] failed to bind approval hooks', { error: err?.message });
131
137
  }
132
138
  }
133
139
 
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  export { SysApprovalRequest } from './sys-approval-request.object.js';
14
14
  export { SysApprovalAction } from './sys-approval-action.object.js';
15
15
  export { SysApprovalApprover } from './sys-approval-approver.object.js';
16
+ export { SysApprovalDelegation } from './sys-approval-delegation.object.js';
16
17
  export {
17
18
  ApprovalService,
18
19
  type ApprovalEngine,
@@ -109,6 +109,70 @@ export function bindApprovalLockHook(engine: MinimalEngine, logger?: MinimalLogg
109
109
  logger?.info?.('[approvals] record-lock hook bound');
110
110
  }
111
111
 
112
+ /** The self-service out-of-office delegation object (#1322). */
113
+ export const DELEGATION_OBJECT = 'sys_approval_delegation';
114
+
115
+ /**
116
+ * Self-service write guard for `sys_approval_delegation` (#1322 follow-up).
117
+ *
118
+ * The object is `apiEnabled` CRUD so a user can declare their own out-of-office
119
+ * delegation. But it is a system object: it gets no auto `owner_id` anchor and
120
+ * (with no `sharingModel`) defaults to a `public` sharing model, so an
121
+ * unguarded member could **forge a delegation for someone else**
122
+ * (`delegator_id = victim`) and reroute the victim's individually-routed
123
+ * approvals to themselves. This guard forces a normal user's writes to name
124
+ * themselves as the delegator:
125
+ *
126
+ * - **system** context (service / seed / import) → bypass;
127
+ * - **admin** (`roles` includes `'admin'`) → may set `delegator_id` to anyone;
128
+ * - otherwise `delegator_id` must equal the acting user — an absent delegator
129
+ * on insert is stamped to the caller, a foreign delegator is rejected.
130
+ *
131
+ * Row-level ownership on update/delete (you can only touch a delegation you
132
+ * created) is already enforced by `member_default`'s wildcard
133
+ * `created_by == current_user.id` RLS; this guard adds the delegator-identity
134
+ * check that RLS alone can't express. Mirrors the ADR-0092 identity write-guard
135
+ * shape and the security plugin's `owner_id` anchor guard, scoped to this one
136
+ * object.
137
+ */
138
+ export function bindDelegationWriteGuard(engine: MinimalEngine, logger?: MinimalLogger): void {
139
+ const makeGuard = (isInsert: boolean) => async (ctx: any) => {
140
+ const session = (ctx?.session ?? {}) as any;
141
+ if (session.isSystem) return; // service / seed / import
142
+ const roles = (session.roles ?? []) as unknown[];
143
+ if (Array.isArray(roles) && roles.includes('admin')) return; // admin may act for anyone
144
+ const userId = session.userId != null ? String(session.userId) : '';
145
+ const data = ctx?.input?.data;
146
+ const rows = Array.isArray(data) ? data : (data && typeof data === 'object' ? [data] : []);
147
+ const deny = (): never => {
148
+ const err: any = new Error(
149
+ 'FORBIDDEN: you may only manage out-of-office delegations where you are the delegator'
150
+ + (userId ? ` ('${userId}')` : ''),
151
+ );
152
+ err.code = 'FORBIDDEN';
153
+ err.statusCode = 403;
154
+ throw err;
155
+ };
156
+ for (const row of rows) {
157
+ if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
158
+ const has = Object.prototype.hasOwnProperty.call(row, 'delegator_id');
159
+ const supplied = has ? String((row as any).delegator_id ?? '') : '';
160
+ if (isInsert && (!has || supplied === '')) {
161
+ // Self-service: stamp the caller as delegator when omitted (the schema's
162
+ // `required` is the fallback if the engine doesn't persist the stamp).
163
+ if (!userId) deny();
164
+ (row as any).delegator_id = userId;
165
+ continue;
166
+ }
167
+ // A foreign delegator on insert (forge) or update (relabel/hijack) → deny.
168
+ if (has && supplied !== userId) deny();
169
+ }
170
+ };
171
+ engine.registerHook('beforeInsert', makeGuard(true), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
172
+ engine.registerHook('beforeUpdate', makeGuard(false), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
173
+ logger?.info?.('[approvals] delegation write-guard bound');
174
+ }
175
+
112
176
  /** Unregister every hook the lock module registered. */
113
177
  export function unbindAllHooks(engine: MinimalEngine): number {
114
178
  return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
@@ -28,6 +28,7 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', ()
28
28
  expect(manifest.objects.map((o: any) => o.name).sort()).toEqual([
29
29
  'sys_approval_action',
30
30
  'sys_approval_approver',
31
+ 'sys_approval_delegation',
31
32
  'sys_approval_request',
32
33
  'sys_approval_token',
33
34
  ]);
@@ -38,6 +39,7 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', ()
38
39
  expect(contribution).toMatchObject({ app: 'setup', group: 'group_approvals' });
39
40
  expect(contribution.items.map((i: any) => i.objectName).sort()).toEqual([
40
41
  'sys_approval_action',
42
+ 'sys_approval_delegation',
41
43
  'sys_approval_request',
42
44
  ]);
43
45
  // Each entry is gated so the slot stays empty when the plugin is absent.
@@ -92,8 +92,9 @@ export const SysApprovalAction = ObjectSchema.create({
92
92
  // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
93
93
  // remind / request_info / comment are thread interactions — they never
94
94
  // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
95
- // revision and the later resubmission.
96
- ['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment', 'revise', 'resubmit'],
95
+ // revision and the later resubmission. ooo_substitute (#1322 M1) is a
96
+ // system-recorded reroute of an out-of-office approver no flow movement.
97
+ ['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment', 'revise', 'resubmit', 'ooo_substitute'],
97
98
  {
98
99
  label: 'Action',
99
100
  required: true,
@@ -109,6 +110,14 @@ export const SysApprovalAction = ObjectSchema.create({
109
110
 
110
111
  comment: Field.textarea({ label: 'Comment', required: false, group: 'Action' }),
111
112
 
113
+ attachments: Field.file({
114
+ label: 'Attachments',
115
+ required: false,
116
+ multiple: true,
117
+ group: 'Action',
118
+ description: 'Files supporting this action — e.g. a signed contract or evidence (#3266).',
119
+ }),
120
+
112
121
  created_at: Field.datetime({
113
122
  label: 'Created At',
114
123
  required: true,
@@ -122,4 +131,10 @@ export const SysApprovalAction = ObjectSchema.create({
122
131
  { fields: ['request_id', 'created_at'] },
123
132
  { fields: ['request_id', 'step_index', 'action'] },
124
133
  ],
134
+
135
+ enable: {
136
+ // [ADR-0103] Engine-owned append-only decision log: appended by the approval
137
+ // engine (SYSTEM_CTX). Reads stay open.
138
+ apiMethods: ['get', 'list'],
139
+ },
125
140
  });
@@ -30,7 +30,7 @@ export const SysApprovalApprover = ObjectSchema.create({
30
30
  pluralLabel: 'Approval Approvers',
31
31
  icon: 'users',
32
32
  isSystem: true,
33
- managedBy: 'system',
33
+ managedBy: 'engine-owned',
34
34
  description: 'Normalized pending-approver rows for indexed inbox queries',
35
35
  displayNameField: 'id',
36
36
  nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
@@ -76,4 +76,10 @@ export const SysApprovalApprover = ObjectSchema.create({
76
76
  // Sync path: rewrite all rows of one request on each approver-set change.
77
77
  { fields: ['request_id'] },
78
78
  ],
79
+
80
+ enable: {
81
+ // [ADR-0103] Engine-owned: approver rows are rewritten by the approval
82
+ // engine (SYSTEM_CTX) on each approver-set change, never via generic CRUD.
83
+ apiMethods: ['get', 'list'],
84
+ },
79
85
  });
@@ -0,0 +1,142 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { ObjectSchema, Field } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * sys_approval_delegation — self-service out-of-office (OOO) delegation (#1322 M1).
7
+ *
8
+ * A standing, self-declared rule: "while I (the delegator) am out between
9
+ * `valid_from` and `valid_until`, route the approver slots that would resolve
10
+ * to me onto my delegate instead." The approval service consults active rows
11
+ * in `ApprovalService.expandApprovers` when resolving an approval node's
12
+ * INDIVIDUALLY-routed approvers (`type: user` / `field` / `manager`) — the
13
+ * delegate becomes a real pending approver and acts under their own identity,
14
+ * so nothing is impersonated and the audit trail stays honest.
15
+ *
16
+ * Modelled as its own object (not a scalar on the better-auth-locked
17
+ * `sys_user`), mirroring the `sys_user_position` delegation precedent
18
+ * (ADR-0091): the validity window is enforced at RESOLUTION time via the
19
+ * shared `isGrantActive` predicate — never by a background job (ADR-0049).
20
+ * The window is half-open `[valid_from, valid_until)` in UTC.
21
+ *
22
+ * Scope note: this is the community-core OOO auto-skip only. Long-term proxy
23
+ * "act-as" access (viewing/acting on another user's full queue under their
24
+ * authority), delegation governance / segregation-of-duties, and org-wide
25
+ * administration of others' delegations are enterprise concerns tracked
26
+ * separately (objectstack-ai/cloud#855), not here.
27
+ *
28
+ * @namespace sys
29
+ */
30
+ export const SysApprovalDelegation = ObjectSchema.create({
31
+ name: 'sys_approval_delegation',
32
+ label: 'Approval Delegation',
33
+ pluralLabel: 'Approval Delegations',
34
+ icon: 'user-clock',
35
+ isSystem: true,
36
+ managedBy: 'system',
37
+ // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
38
+ // authors their own out-of-office delegation. Affordance only (matches the
39
+ // full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
40
+ // keeps the system write guard from rejecting the self-service write.
41
+ userActions: { create: true, edit: true, delete: true },
42
+ description:
43
+ 'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).',
44
+ titleFormat: '{delegator_id} → {delegate_id}',
45
+ highlightFields: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until'],
46
+
47
+ listViews: {
48
+ active: {
49
+ type: 'grid',
50
+ name: 'active',
51
+ label: 'Active',
52
+ data: { provider: 'object', object: 'sys_approval_delegation' },
53
+ columns: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason'],
54
+ sort: [{ field: 'valid_until', order: 'asc' }],
55
+ pagination: { pageSize: 50 },
56
+ emptyState: {
57
+ title: 'No delegations',
58
+ message: 'Declare an out-of-office delegation so approvals route to a backup while you are away.',
59
+ },
60
+ },
61
+ },
62
+
63
+ fields: {
64
+ id: Field.text({ label: 'Delegation ID', required: true, readonly: true, group: 'System' }),
65
+
66
+ delegator_id: Field.lookup('sys_user', {
67
+ label: 'Delegator',
68
+ required: true,
69
+ group: 'Delegation',
70
+ description: 'The user going out of office; their individually-routed approver slots are rerouted while active.',
71
+ }),
72
+
73
+ delegate_id: Field.lookup('sys_user', {
74
+ label: 'Delegate',
75
+ required: true,
76
+ group: 'Delegation',
77
+ description: 'The backup who receives the delegator\'s approvals while this rule is active. Acts under their own identity.',
78
+ }),
79
+
80
+ valid_from: Field.datetime({
81
+ label: 'Valid From',
82
+ required: false,
83
+ group: 'Delegation',
84
+ description:
85
+ 'Rule is inactive before this instant. Null = active immediately. ' +
86
+ 'Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job.',
87
+ }),
88
+
89
+ valid_until: Field.datetime({
90
+ label: 'Valid Until',
91
+ required: false,
92
+ group: 'Delegation',
93
+ description:
94
+ 'Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires.',
95
+ }),
96
+
97
+ reason: Field.text({
98
+ label: 'Reason',
99
+ required: false,
100
+ maxLength: 500,
101
+ group: 'Delegation',
102
+ description: 'Why the delegation exists (e.g. "Annual leave 5/26–5/30"). Recorded on the substitution audit row.',
103
+ }),
104
+
105
+ organization_id: Field.lookup('sys_organization', {
106
+ label: 'Organization',
107
+ required: false,
108
+ group: 'System',
109
+ description: 'Tenant that owns this rule; null = applies across tenants for this delegator.',
110
+ }),
111
+
112
+ created_at: Field.datetime({
113
+ label: 'Created At',
114
+ defaultValue: 'NOW()',
115
+ readonly: true,
116
+ group: 'System',
117
+ }),
118
+
119
+ updated_at: Field.datetime({
120
+ label: 'Updated At',
121
+ defaultValue: 'NOW()',
122
+ readonly: true,
123
+ group: 'System',
124
+ }),
125
+ },
126
+
127
+ indexes: [
128
+ // Resolution-time lookup: "active delegations for this delegator".
129
+ { fields: ['delegator_id', 'organization_id'] },
130
+ { fields: ['delegate_id'] },
131
+ { fields: ['valid_until'] },
132
+ ],
133
+
134
+ enable: {
135
+ trackHistory: true,
136
+ searchable: true,
137
+ apiEnabled: true,
138
+ apiMethods: ['get', 'list', 'create', 'update', 'delete'],
139
+ trash: true,
140
+ mru: false,
141
+ },
142
+ });
@@ -0,0 +1,103 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Contract test for the server-declared decision actions on
5
+ * `sys_approval_request` (objectui#2678 P2-4 / retire-hardcoded-buttons).
6
+ *
7
+ * The console's generic action runtime renders + executes these wherever the
8
+ * object is surfaced (the approvals inbox included), so the inbox no longer
9
+ * hand-writes a button per capability. That only holds if the declared set
10
+ * stays faithful to the REST routes it targets and to the who-can-act gating.
11
+ * This pins both:
12
+ *
13
+ * • every `type:'api'` target resolves `{id}` and points at a route that the
14
+ * REST server actually registers (approve/reject/reassign/recall/remind/
15
+ * request-info/revise/resubmit) — a typo'd verb would 404 silently in the UI;
16
+ * • submitter-only levers (remind/recall/resubmit) gate on
17
+ * `submitter_id == ctx.user.id` so a non-submitter never sees them.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { SysApprovalRequest } from './sys-approval-request.object.js';
22
+
23
+ const actions = (SysApprovalRequest as any).actions as any[];
24
+ const byName = (n: string) => actions.find((a) => a.name === n);
25
+ /** `ObjectSchema.create` normalizes `visible` strings into a
26
+ * `{ dialect: 'cel', source }` envelope — read the source for substring asserts. */
27
+ const vis = (n: string): string => {
28
+ const v = byName(n).visible;
29
+ return typeof v === 'string' ? v : String(v?.source ?? '');
30
+ };
31
+
32
+ /** Verbs the REST server registers under `/api/v1/approvals/requests/:id/*`. */
33
+ const ROUTE_VERBS = new Set([
34
+ 'approve', 'reject', 'reassign', 'recall', 'remind', 'request-info', 'revise', 'resubmit',
35
+ ]);
36
+
37
+ describe('sys_approval_request declared actions', () => {
38
+ it('declares the full decision + continuity set', () => {
39
+ expect(actions.map((a) => a.name).sort()).toEqual(
40
+ [
41
+ 'approval_approve',
42
+ 'approval_recall',
43
+ 'approval_reassign',
44
+ 'approval_reject',
45
+ 'approval_remind',
46
+ 'approval_request_info',
47
+ 'approval_resubmit',
48
+ 'approval_send_back',
49
+ ].sort(),
50
+ );
51
+ });
52
+
53
+ it('every api target points at a registered approvals route verb and injects {id}', () => {
54
+ for (const a of actions) {
55
+ expect(a.type).toBe('api');
56
+ expect(a.method).toBe('POST');
57
+ const m = /^\/api\/v1\/approvals\/requests\/\{id\}\/([a-z-]+)$/.exec(a.target);
58
+ expect(m, `${a.name} target ${a.target}`).not.toBeNull();
59
+ expect(ROUTE_VERBS.has(m![1]), `${a.name} → ${m![1]}`).toBe(true);
60
+ expect(a.refreshAfter).toBe(true);
61
+ }
62
+ });
63
+
64
+ it('gates on the server-computed viewer block (#3310): approver actions on can_act, submitter levers on is_submitter', () => {
65
+ for (const name of ['approval_remind', 'approval_recall', 'approval_resubmit']) {
66
+ expect(vis(name)).toContain('record.viewer.is_submitter');
67
+ expect(vis(name)).not.toContain('can_act');
68
+ }
69
+ for (const name of ['approval_approve', 'approval_reject', 'approval_send_back', 'approval_request_info', 'approval_reassign']) {
70
+ expect(vis(name)).toContain('record.viewer.can_act');
71
+ // who-can-act is server-derived, never a client identity guess
72
+ expect(vis(name)).not.toContain('ctx.user.id');
73
+ expect(vis(name)).not.toContain('is_submitter');
74
+ }
75
+ });
76
+
77
+ it('recall stays available while a returned request is still the submitter\'s to abandon', () => {
78
+ expect(vis('approval_recall')).toContain('record.status == "returned"');
79
+ expect(byName('approval_recall').confirmText).toBeTruthy();
80
+ });
81
+
82
+ it('reassign collects the new approver via a field-backed sys_user picker keyed as `to`', () => {
83
+ const toParam = byName('approval_reassign').params.find((p: any) => p.name === 'to');
84
+ expect(toParam).toMatchObject({ field: 'submitter_id', name: 'to', required: true });
85
+ });
86
+
87
+ it('request-info requires a comment; other params stay optional', () => {
88
+ const ri = byName('approval_request_info').params.find((p: any) => p.name === 'comment');
89
+ expect(ri.required).toBe(true);
90
+ for (const name of ['approval_send_back', 'approval_remind', 'approval_recall', 'approval_resubmit']) {
91
+ const c = byName(name).params.find((p: any) => p.name === 'comment');
92
+ expect(c.required ?? false).toBe(false);
93
+ }
94
+ });
95
+
96
+ it('approve/reject collect optional multi-file decision attachments', () => {
97
+ for (const name of ['approval_approve', 'approval_reject']) {
98
+ const att = byName(name).params.find((p: any) => p.name === 'attachments');
99
+ expect(att, `${name}.attachments`).toMatchObject({ type: 'file', multiple: true });
100
+ expect(att.required ?? false).toBe(false);
101
+ }
102
+ });
103
+ });
@@ -28,7 +28,7 @@ export const SysApprovalRequest = ObjectSchema.create({
28
28
  pluralLabel: 'Approval Requests',
29
29
  icon: 'inbox',
30
30
  isSystem: true,
31
- managedBy: 'system',
31
+ managedBy: 'engine-owned',
32
32
  description: 'Live approval instance tracked per submission',
33
33
  displayNameField: 'id',
34
34
  nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
@@ -229,4 +229,173 @@ export const SysApprovalRequest = ObjectSchema.create({
229
229
  { fields: ['status', 'updated_at'] },
230
230
  { fields: ['submitter_id', 'status'] },
231
231
  ],
232
+
233
+ // Server-declared decision actions (objectui#2678 P2-4). The console's
234
+ // generic action runtime renders and executes these wherever this object is
235
+ // surfaced — the approvals inbox included — so new decision capabilities
236
+ // (and their params) ship as metadata, not as hand-written buttons. Each
237
+ // targets the existing approvals REST route; `{id}` resolves from the row
238
+ // and `actorId` defaults to the caller server-side. The service remains the
239
+ // authority on who may act; `visible` gates on the server-computed
240
+ // per-viewer block (#3310): approver actions on `record.viewer.can_act`
241
+ // (the caller is a current pending approver — same check the service
242
+ // authorizes a decision with, so position/team approvers resolve correctly),
243
+ // submitter actions on `record.viewer.is_submitter`. `viewer` is attached by
244
+ // getRequest/listRequests; where it is absent the predicate fails closed.
245
+ actions: [
246
+ {
247
+ name: 'approval_approve',
248
+ label: 'Approve',
249
+ icon: 'check-circle',
250
+ type: 'api',
251
+ method: 'POST',
252
+ target: '/api/v1/approvals/requests/{id}/approve',
253
+ params: [
254
+ { name: 'comment', label: 'Comment', type: 'textarea', required: false },
255
+ // Decision attachments (#3266). The console renders `type:'file'` params
256
+ // through the shared upload widget and POSTs the resolved `attachments:
257
+ // string[]`; the decision route persists them on `sys_approval_action`.
258
+ { name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
259
+ ],
260
+ visible: 'record.viewer.can_act',
261
+ locations: ['record_section', 'list_item'],
262
+ successMessage: 'Approved.',
263
+ refreshAfter: true,
264
+ },
265
+ {
266
+ name: 'approval_reject',
267
+ label: 'Reject',
268
+ icon: 'x-circle',
269
+ type: 'api',
270
+ method: 'POST',
271
+ target: '/api/v1/approvals/requests/{id}/reject',
272
+ params: [
273
+ { name: 'comment', label: 'Comment', type: 'textarea', required: false },
274
+ { name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
275
+ ],
276
+ visible: 'record.viewer.can_act',
277
+ confirmText: 'Reject this request? A rejection is final for every approver.',
278
+ locations: ['record_section', 'list_item'],
279
+ successMessage: 'Rejected.',
280
+ refreshAfter: true,
281
+ },
282
+ {
283
+ name: 'approval_reassign',
284
+ label: 'Reassign',
285
+ icon: 'arrow-right-left',
286
+ type: 'api',
287
+ method: 'POST',
288
+ target: '/api/v1/approvals/requests/{id}/reassign',
289
+ params: [
290
+ // Field-backed on `submitter_id` (the object's only `sys_user` lookup):
291
+ // the console resolves its lookup config (`reference_to: sys_user`) so the
292
+ // dialog renders a real user picker, while `name: 'to'` overrides the
293
+ // request-body key to the `to` the reassign route expects. This is a
294
+ // config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
295
+ { field: 'submitter_id', name: 'to', label: 'New approver', required: true, helpText: 'User to hand this step to' },
296
+ { name: 'comment', label: 'Comment', type: 'textarea', required: false },
297
+ ],
298
+ visible: 'record.viewer.can_act',
299
+ locations: ['record_section'],
300
+ successMessage: 'Reassigned.',
301
+ refreshAfter: true,
302
+ },
303
+
304
+ // ── Approver secondary decisions ────────────────────────────────
305
+ // Send back for revision / request more info (ADR-0044). Both are approver
306
+ // actions, so `visible` gates on `record.viewer.can_act` (a current pending
307
+ // approver) — same as approve/reject. The service stays the authority.
308
+ {
309
+ name: 'approval_send_back',
310
+ label: 'Send back',
311
+ icon: 'corner-up-left',
312
+ type: 'api',
313
+ method: 'POST',
314
+ target: '/api/v1/approvals/requests/{id}/revise',
315
+ params: [
316
+ { name: 'comment', label: 'Reason', type: 'textarea', required: false },
317
+ ],
318
+ visible: 'record.viewer.can_act',
319
+ locations: ['record_section'],
320
+ successMessage: 'Sent back for revision.',
321
+ refreshAfter: true,
322
+ },
323
+ {
324
+ name: 'approval_request_info',
325
+ label: 'Request info',
326
+ icon: 'help-circle',
327
+ type: 'api',
328
+ method: 'POST',
329
+ target: '/api/v1/approvals/requests/{id}/request-info',
330
+ params: [
331
+ { name: 'comment', label: 'What do you need?', type: 'textarea', required: true },
332
+ ],
333
+ visible: 'record.viewer.can_act',
334
+ locations: ['record_section'],
335
+ successMessage: 'Information requested.',
336
+ refreshAfter: true,
337
+ },
338
+
339
+ // ── Submitter continuity actions ────────────────────────────────
340
+ // Remind / recall (pending) and resubmit / recall (returned). These are the
341
+ // submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
342
+ // (server-computed on the current viewer). The service re-checks ownership;
343
+ // the predicate keeps a non-submitter from ever seeing a button they cannot
344
+ // use.
345
+ {
346
+ name: 'approval_remind',
347
+ label: 'Send reminder',
348
+ icon: 'bell-ring',
349
+ type: 'api',
350
+ method: 'POST',
351
+ target: '/api/v1/approvals/requests/{id}/remind',
352
+ params: [
353
+ { name: 'comment', label: 'Note', type: 'textarea', required: false },
354
+ ],
355
+ visible: 'record.status == "pending" && record.viewer.is_submitter',
356
+ locations: ['record_section'],
357
+ successMessage: 'Reminder sent.',
358
+ refreshAfter: true,
359
+ },
360
+ {
361
+ name: 'approval_recall',
362
+ label: 'Recall',
363
+ icon: 'undo-2',
364
+ type: 'api',
365
+ method: 'POST',
366
+ target: '/api/v1/approvals/requests/{id}/recall',
367
+ params: [
368
+ { name: 'comment', label: 'Comment', type: 'textarea', required: false },
369
+ ],
370
+ // Recall applies while the request is live for the submitter — pending
371
+ // (withdraw) or returned (abandon the revision instead of resubmitting).
372
+ visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
373
+ confirmText: 'Recall this request? Approvers can no longer act on it and the record is unlocked.',
374
+ locations: ['record_section'],
375
+ successMessage: 'Recalled.',
376
+ refreshAfter: true,
377
+ },
378
+ {
379
+ name: 'approval_resubmit',
380
+ label: 'Resubmit',
381
+ icon: 'refresh-cw',
382
+ type: 'api',
383
+ method: 'POST',
384
+ target: '/api/v1/approvals/requests/{id}/resubmit',
385
+ params: [
386
+ { name: 'comment', label: 'What changed?', type: 'textarea', required: false },
387
+ ],
388
+ visible: 'record.status == "returned" && record.viewer.is_submitter',
389
+ locations: ['record_section'],
390
+ successMessage: 'Resubmitted.',
391
+ refreshAfter: true,
392
+ },
393
+ ],
394
+
395
+ enable: {
396
+ // [ADR-0103] Engine-owned: the approval engine owns the request lifecycle
397
+ // (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never
398
+ // generic CRUD. Reads stay open.
399
+ apiMethods: ['get', 'list'],
400
+ },
232
401
  });
@@ -19,7 +19,7 @@ export const SysApprovalToken = ObjectSchema.create({
19
19
  pluralLabel: 'Approval Action Tokens',
20
20
  icon: 'key',
21
21
  isSystem: true,
22
- managedBy: 'system',
22
+ managedBy: 'engine-owned',
23
23
  description: 'Single-use tokens behind actionable approval links',
24
24
  displayNameField: 'id',
25
25
  nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
@@ -92,4 +92,10 @@ export const SysApprovalToken = ObjectSchema.create({
92
92
  { fields: ['token_hash'] },
93
93
  { fields: ['request_id'] },
94
94
  ],
95
+
96
+ enable: {
97
+ // [ADR-0103] Engine-owned: one-time email-approval tokens are minted and
98
+ // consumed by the approval engine (SYSTEM_CTX), never via the data API.
99
+ apiMethods: ['get', 'list'],
100
+ },
95
101
  });