From 3c25cf889b8fc7987cd7c43314d16191b8b30307 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 18:32:06 +0500 Subject: [PATCH] feat(approvals): recall endpoint + business-readable inbox contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Console approvals inbox surfaced four service-level gaps (found via e2e review of the showcase app): - Recall was advertised in the contract (status 'recalled', action 'recall') and rendered in the Console UI, but never implemented — the button always died with HTTP 404. Implement ApprovalService.recall() (submitter-only, audits a 'recall' action, mirrors the status field, resumes the owning flow run down the reject branch with output.decision='recall' since the engine has no run-cancel primitive) and register POST /approvals/requests/:id/recall. - Rows never carried submitted_at; the inbox showed "—" for every request and its newest-first sort compared empty strings. Expose submitted_at as an alias of created_at on the row mapper. - Requests displayed machine names (flow:manager_review, opaque record ids, raw user ids). Seed $flowName/$flowLabel into engine variables, snapshot authored flow/node labels onto node_config_json (no schema migration), and surface process_label/step_label with a prettified fallback for legacy rows. Enrich listRequests/getRequest with record_title (schema displayNameField, payload-snapshot fallback) and submitter_name (sys_user by id or email), batched per object. Co-Authored-By: Claude Fable 5 --- .../src/approval-node.test.ts | 14 ++ .../plugin-approvals/src/approval-node.ts | 10 +- .../src/approval-service.test.ts | 92 ++++++++ .../plugin-approvals/src/approval-service.ts | 205 +++++++++++++++++- packages/rest/src/rest-server.ts | 31 +++ .../services/service-automation/src/engine.ts | 5 + .../spec/src/contracts/approval-service.ts | 43 ++++ 7 files changed, 397 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/approval-node.test.ts b/packages/plugins/plugin-approvals/src/approval-node.test.ts index 6356205bd5..8f482ad567 100644 --- a/packages/plugins/plugin-approvals/src/approval-node.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-node.test.ts @@ -122,6 +122,20 @@ describe('Approval node bridge (ADR-0019)', () => { expect(suspended[0]).toMatchObject({ nodeId: 'approve_step', correlation: requests[0].id }); }); + it('carries the flow name + authored labels onto the request row', async () => { + registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]); + await automation.execute('deal_approval', { + object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter', + }); + const [raw] = await fake.find('sys_approval_request', { where: { status: 'pending' } }); + // Engine-seeded `$flowName` (not the node id) names the source… + expect(raw.process_name).toBe('flow:deal_approval'); + // …and authored labels ride the config snapshot for inbox display. + const req = (await service.listRequests({ status: 'pending' }, { isSystem: true } as any))[0]; + expect(req.process_label).toBe('Deal Approval'); + expect(req.step_label).toBe('Manager Approval'); + }); + it('resumes down the approve branch on approval', async () => { registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]); const paused = await automation.execute('deal_approval', { diff --git a/packages/plugins/plugin-approvals/src/approval-node.ts b/packages/plugins/plugin-approvals/src/approval-node.ts index c6d52fa1bd..c57e1c94c0 100644 --- a/packages/plugins/plugin-approvals/src/approval-node.ts +++ b/packages/plugins/plugin-approvals/src/approval-node.ts @@ -97,6 +97,12 @@ export function registerApprovalNode( if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` }; if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` }; + // Flow identity comes from engine-seeded variables (`$flowName` / + // `$flowLabel`) so the request row can carry a human-readable origin; + // `context.flowName` is a legacy fallback for direct callers. + const flowName = (variables.get('$flowName') as string | undefined) ?? context?.flowName; + const flowLabel = variables.get('$flowLabel') as string | undefined; + try { const request = await service.openNodeRequest({ object, @@ -104,7 +110,9 @@ export function registerApprovalNode( runId: String(runId), nodeId: node.id, config, - flowName: context?.flowName, + flowName, + flowLabel, + nodeLabel: typeof node.label === 'string' ? node.label : undefined, submitterId: context?.userId ?? null, record, organizationId: context?.organizationId ?? context?.tenantId ?? null, diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 6fd2b21f55..065a2e4850 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -290,6 +290,98 @@ describe('ApprovalService (node era)', () => { it('getRequest: returns null for an unknown id', async () => { expect(await svc.getRequest('nope', SYS)).toBeNull(); }); + + // ── recall ────────────────────────────────────────────────────── + + it('recall: submitter withdraws a pending request', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + const out = await svc.recall(req.id, { actorId: 'u1', comment: 'changed my mind' }, CTX); + expect(out.request.status).toBe('recalled'); + expect(out.request.completed_at).toBeTruthy(); + expect(out.request.pending_approvers).toEqual([]); + const actions = await svc.listActions(req.id, SYS); + expect(actions.map(a => a.action)).toEqual(['submit', 'recall']); + expect(actions[1].comment).toBe('changed my mind'); + }); + + it('recall: blocks a non-submitter in a non-system context', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + await expect(svc.recall(req.id, { actorId: 'u9' }, { roles: [], permissions: [] } as any)) + .rejects.toThrow(/FORBIDDEN/); + }); + + it('recall: rejects a recall on a non-pending request', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS); + await expect(svc.recall(req.id, { actorId: 'u1' }, SYS)).rejects.toThrow(/INVALID_STATE/); + }); + + it('recall: resumes the owning run down the reject branch with decision=recall', async () => { + const resumed: any[] = []; + svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } }); + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + const out = await svc.recall(req.id, { actorId: 'u1' }, CTX); + expect(out.resumed).toBe(true); + expect(resumed[0]).toMatchObject({ + runId: 'run_1', + signal: { branchLabel: 'reject', output: { decision: 'recall' } }, + }); + }); + + it('recall: mirrors `recalled` onto the business record when configured', async () => { + engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }]; + const req = await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX); + await svc.recall(req.id, { actorId: 'u1' }, CTX); + expect(engine._tables['opportunity'][0].approval_status).toBe('recalled'); + }); + + // ── inbox display fields ──────────────────────────────────────── + + it('rows expose submitted_at as an alias of created_at', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + expect(req.submitted_at).toBeTruthy(); + expect(req.submitted_at).toBe(req.created_at); + const listed = await svc.listRequests({ status: 'pending' }, SYS); + expect(listed[0].submitted_at).toBe(listed[0].created_at); + }); + + it('rows carry authored flow/node labels when provided', async () => { + const req = await svc.openNodeRequest( + openInput(['u9'], { flowLabel: 'Deal Approval', nodeLabel: 'Manager Review' }), CTX, + ); + expect(req.process_label).toBe('Deal Approval'); + expect(req.step_label).toBe('Manager Review'); + }); + + it('rows fall back to prettified machine names when labels are absent', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + expect(req.process_label).toBe('Deal Approval'); // from `flow:deal_approval` + expect(req.step_label).toBe('Approve Step'); // from `approve_step` + }); + + it('listRequests enriches record_title and submitter_name', async () => { + engine._tables['opportunity'] = [{ id: 'opp1', name: 'Acme Renewal', amount: 100 }]; + engine._tables['sys_user'] = [{ id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' }]; + await svc.openNodeRequest(openInput(['u9']), CTX); // submitter_id = u1 (CTX.userId) + const rows = await svc.listRequests({ status: 'pending' }, SYS); + expect(rows[0].record_title).toBe('Acme Renewal'); + expect(rows[0].submitter_name).toBe('Ada Lovelace'); + }); + + it('enrichment falls back to the payload snapshot when the record is gone', async () => { + await svc.openNodeRequest( + openInput(['u9'], { record: { id: 'opp1', name: 'Snapshot Title', amount: 1 } }), CTX, + ); + const rows = await svc.listRequests({ status: 'pending' }, SYS); + expect(rows[0].record_title).toBe('Snapshot Title'); + }); + + it('enrichment resolves an email submitter via sys_user.email', async () => { + engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }]; + await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX); + const rows = await svc.listRequests({ status: 'pending' }, SYS); + expect(rows[0].submitter_name).toBe('Grace Hopper'); + }); }); describe('record-lock hook (node era)', () => { diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 4c5d1a3a08..784fc14fd7 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -10,6 +10,8 @@ import type { ApprovalActionRow, ApprovalDecisionInput, ApprovalDecisionResult, + ApprovalRecallInput, + ApprovalRecallResult, ApprovalStatus, SharingExecutionContext, } from '@objectstack/spec/contracts'; @@ -69,7 +71,27 @@ function csvSplit(raw: unknown): string[] { return String(raw).split(',').map(s => s.trim()).filter(Boolean); } +/** + * Humanize a machine name for display fallback: strips a `flow:` prefix and + * title-cases underscore/dash segments (`flow:manager_review` → "Manager + * Review"). Used only when no authored label was snapshotted on the row. + */ +function prettifyMachineName(raw: string | null | undefined): string | undefined { + if (!raw) return undefined; + const base = String(raw).replace(/^flow:/, '').trim(); + if (!base) return undefined; + return base + .split(/[_\-\s]+/) + .filter(Boolean) + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + function rowFromRequest(row: any): ApprovalRequestRow { + // Authored display labels ride the node-config snapshot (`__flowLabel` / + // `__nodeLabel`) so they survive without a schema migration; fall back to a + // prettified machine name for rows written before labels were captured. + const cfg = parseJson(row.node_config_json, undefined); return { id: String(row.id), organization_id: row.organization_id ?? undefined, @@ -88,6 +110,10 @@ function rowFromRequest(row: any): ApprovalRequestRow { completed_at: row.completed_at ?? undefined, created_at: row.created_at ?? undefined, updated_at: row.updated_at ?? undefined, + // The row is created at submission time; expose the stable inbox-facing name. + submitted_at: row.created_at ?? undefined, + process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name), + step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step), } as any; } @@ -291,6 +317,10 @@ export class ApprovalService implements IApprovalService { nodeId: string; config: ApprovalNodeConfig; flowName?: string; + /** Authored flow label, snapshotted for inbox display. */ + flowLabel?: string; + /** Authored node label, snapshotted for inbox display. */ + nodeLabel?: string; submitterId?: string | null; record?: any; organizationId?: string | null; @@ -316,6 +346,11 @@ export class ApprovalService implements IApprovalService { const now = this.clock.now().toISOString(); const id = uid('areq'); const processName = `flow:${input.flowName ?? input.nodeId}`; + // Display labels ride the config snapshot (no schema migration needed); + // `rowFromRequest` surfaces them as `process_label` / `step_label`. + const configSnapshot: any = { ...input.config }; + if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel; + if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel; const row: any = { id, process_name: processName, @@ -329,7 +364,7 @@ export class ApprovalService implements IApprovalService { payload_json: input.record != null ? JSON.stringify(input.record) : null, flow_run_id: input.runId, flow_node_id: input.nodeId, - node_config_json: JSON.stringify(input.config), + node_config_json: JSON.stringify(configSnapshot), organization_id: ctxOrg, created_at: now, updated_at: now, @@ -463,6 +498,168 @@ export class ApprovalService implements IApprovalService { }; } + /** + * Withdraw a pending request (submitter only). Finalises the row as + * `recalled`, releases the record lock (keyed on pending status), mirrors + * the status field when configured, and resumes the owning flow run down + * the `reject` branch with `output.decision = 'recall'` — the engine has no + * run-cancel primitive, and leaving the run suspended forever would leak it. + */ + async recall( + requestId: string, + input: ApprovalRecallInput, + context: SharingExecutionContext, + ): Promise { + if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required'); + if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required'); + + const rawRows = await this.engine.find('sys_approval_request', { + where: { id: requestId }, limit: 1, context: SYSTEM_CTX, + }); + const raw: any = Array.isArray(rawRows) ? rawRows[0] : null; + if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`); + if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`); + if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) { + throw new Error(`FORBIDDEN: only the submitter may recall this request`); + } + + const config = parseJson(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any); + const org = raw.organization_id ?? null; + const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null; + const runId: string | null = raw.flow_run_id ?? null; + const now = this.clock.now().toISOString(); + + await this.engine.insert('sys_approval_action', { + id: uid('aact'), request_id: requestId, organization_id: org, + step_name: nodeId, step_index: 0, action: 'recall', + actor_id: input.actorId, comment: input.comment ?? null, created_at: now, + }, { context: SYSTEM_CTX }); + + await this.engine.update('sys_approval_request', { + id: requestId, status: 'recalled', pending_approvers: null, completed_at: now, updated_at: now, + }, { context: SYSTEM_CTX }); + if (config.approvalStatusField) { + await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'recalled'); + } + + let resumed = false; + if (runId && typeof this.automation?.resume === 'function') { + try { + await this.automation.resume(runId, { + branchLabel: APPROVAL_BRANCH_LABELS.reject, + output: { decision: 'recall', requestId }, + }); + resumed = true; + } catch (err: any) { + this.logger?.warn?.('[approvals] resume after recall failed', { + request: requestId, run: runId, error: err?.message ?? String(err), + }); + } + } + + const fresh = await this.getRequest(requestId, context); + return { request: fresh!, runId, resumed }; + } + + // ── Display enrichment ─────────────────────────────────────── + + /** + * Resolve the schema-declared display field for an object, when the engine + * exposes schema metadata (`getSchema`). Falls back to common title-ish + * field names so plain `ApprovalEngine` fakes still enrich sensibly. + */ + private resolveDisplayField(object: string): string | undefined { + try { + const schema: any = (this.engine as any).getSchema?.(object); + const fields = schema?.fields ?? {}; + const declared = schema?.displayNameField; + if (declared && declared !== 'id' && fields[declared]) return declared; + for (const cand of ['name', 'title', 'subject', 'label']) { + if (fields[cand]) return cand; + } + } catch { /* schema unavailable — heuristics below still apply */ } + return undefined; + } + + private static pickTitle(rec: any, displayField?: string): string | undefined { + const candidates = displayField + ? [displayField, 'name', 'title', 'subject', 'label'] + : ['name', 'title', 'subject', 'label']; + for (const f of candidates) { + const v = rec?.[f]; + if (v != null && String(v).trim() && f !== 'id') return String(v); + } + return undefined; + } + + /** + * Attach inbox display fields (`record_title`, `submitter_name`) to rows. + * Batched: one query per distinct target object plus one `sys_user` lookup. + * Best-effort — a deleted record falls back to the payload snapshot, and a + * lookup failure leaves the field unset rather than failing the list. + */ + private async enrichRows(rows: ApprovalRequestRow[]): Promise { + if (!rows.length) return; + + // Record titles, batched per object. + const byObject = new Map>(); + for (const r of rows) { + if (!r.object_name || !r.record_id) continue; + let set = byObject.get(r.object_name); + if (!set) { set = new Set(); byObject.set(r.object_name, set); } + set.add(r.record_id); + } + const titles = new Map(); + for (const [object, idSet] of byObject) { + const ids = Array.from(idSet); + const displayField = this.resolveDisplayField(object); + try { + const recs = await this.engine.find(object, { + where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX, + }); + for (const rec of (recs ?? []) as any[]) { + const title = ApprovalService.pickTitle(rec, displayField); + if (rec?.id && title) titles.set(`${object}${rec.id}`, title); + } + } catch { /* object may be unregistered — payload fallback below */ } + } + + // Submitter display names — submitter_id may be a user id or an email. + const submitters = Array.from(new Set(rows.map(r => r.submitter_id).filter(Boolean))) as string[]; + const names = new Map(); + if (submitters.length) { + try { + const users = await this.engine.find('sys_user', { + where: { id: { $in: submitters } }, fields: ['id', 'name', 'email'], + limit: submitters.length, context: SYSTEM_CTX, + }); + for (const u of (users ?? []) as any[]) { + if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email)); + } + } catch { /* best-effort */ } + const unresolvedEmails = submitters.filter(s => !names.has(s) && s.includes('@')); + if (unresolvedEmails.length) { + try { + const users = await this.engine.find('sys_user', { + where: { email: { $in: unresolvedEmails } }, fields: ['email', 'name'], + limit: unresolvedEmails.length, context: SYSTEM_CTX, + }); + for (const u of (users ?? []) as any[]) { + if (u?.email && u.name) names.set(String(u.email), String(u.name)); + } + } catch { /* best-effort */ } + } + } + + for (const r of rows as any[]) { + const title = titles.get(`${r.object_name}${r.record_id}`) + ?? ApprovalService.pickTitle(r.payload, undefined); + if (title) r.record_title = title; + const name = r.submitter_id ? names.get(String(r.submitter_id)) : undefined; + if (name) r.submitter_name = name; + } + } + // ── Read API ───────────────────────────────────────────────── async listRequests( @@ -513,6 +710,7 @@ export class ApprovalService implements IApprovalService { }); } } + await this.enrichRows(list); return list; } @@ -524,7 +722,10 @@ export class ApprovalService implements IApprovalService { const rows = await this.engine.find('sys_approval_request', { where, limit: 1, context: SYSTEM_CTX, }); - return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null; + if (!Array.isArray(rows) || !rows[0]) return null; + const row = rowFromRequest(rows[0]); + await this.enrichRows([row]); + return row; } async listActions(requestId: string, context: SharingExecutionContext): Promise { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e2a668cc74..f4444a1f27 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -4370,6 +4370,37 @@ export class RestServer { decisionRoute('approve'); decisionRoute('reject'); + // Recall — submitter withdraws a pending request. Mirrors the decision + // routes' error mapping; the service enforces submitter-only access. + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/approvals/requests/:id/recall`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc || typeof svc.recall !== 'function') return respond501(res); + const body = req.body ?? {}; + try { + const out = await svc.recall(req.params.id, { + actorId: body.actorId ?? body.actor_id ?? context?.userId, + comment: body.comment, + }, context ?? {}); + res.json(out); + } catch (err: any) { + if (handleApprovalError(res, err)) return; + throw err; + } + } catch (error: any) { + logError('[REST] recall approval error:', error); + res.status(500).json({ code: 'APPROVAL_RECALL_FAILED', error: String(error?.message ?? error).slice(0, 500) }); + } + }, + metadata: { summary: 'Recall (withdraw) an approval request', tags: ['approvals'] }, + }); + this.routeManager.register({ method: 'GET', path: `${dataPath}/approvals/requests/:id/actions`, diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 2dbaccf52f..343c04a603 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -863,6 +863,11 @@ export class AutomationEngine implements IAutomationService { // Expose the run id to executors (ADR-0019): a pausing node (e.g. Approval) // reads `$runId` to map its external state back to this run for resume. variables.set('$runId', runId); + // Expose flow identity to executors so externalized state (e.g. an + // approval request row) can carry a human-readable origin. Captured in + // the variable snapshot, so still present after a suspend/resume. + variables.set('$flowName', flowName); + variables.set('$flowLabel', flow.label ?? flowName); const startedAt = new Date().toISOString(); const steps: StepLogEntry[] = []; diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index 39030759ee..9054d316fe 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -42,6 +42,21 @@ export interface ApprovalRequestRow { completed_at?: string; created_at?: string; updated_at?: string; + /** + * When the request was opened. Alias of `created_at` — the row is created + * at submission time. Kept as its own field so inbox clients have a stable + * name that survives any future split between row-creation and submission. + */ + submitted_at?: string; + // ── Display enrichment (inbox-facing; resolved by the service) ───── + /** Human label of the originating flow (e.g. "Project Budget Approval"). */ + process_label?: string; + /** Human label of the approval step / node (e.g. "Manager Review"). */ + step_label?: string; + /** Display name of the target record (its name/title field), when resolvable. */ + record_title?: string; + /** Display name of the submitter (`sys_user.name`), when resolvable. */ + submitter_name?: string; } /** Audit row. */ @@ -63,6 +78,27 @@ export interface ApprovalDecisionInput { comment?: string; } +/** Input for recalling (withdrawing) a pending request. */ +export interface ApprovalRecallInput { + /** Must be the request's submitter (or a system context). */ + actorId: string; + comment?: string; +} + +/** Result of a recall. */ +export interface ApprovalRecallResult { + request: ApprovalRequestRow; + /** The suspended flow run this request gated, if any. */ + runId?: string | null; + /** + * True when the owning flow run was resumed (down the `reject` branch with + * `output.decision = 'recall'`) so it doesn't stay suspended forever. The + * engine has no run-cancel primitive yet; the reject edge is the closest + * "did not pass" semantics. + */ + resumed?: boolean; +} + /** Result of a decision that resumes the owning flow when finalised. */ export interface ApprovalDecisionResult { request: ApprovalRequestRow; @@ -110,6 +146,13 @@ export interface IApprovalService { */ decide(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise; + /** + * Withdraw a pending request. Only the submitter (or a system context) may + * recall. Finalises the request as `recalled` and resumes the owning flow + * run down the `reject` branch with `output.decision = 'recall'`. + */ + recall(requestId: string, input: ApprovalRecallInput, context: SharingExecutionContext): Promise; + /** Audit trail for a request. */ listActions(requestId: string, context: SharingExecutionContext): Promise; }