diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx
index 121697c5d9..abd31296f5 100644
--- a/content/docs/automation/approvals.mdx
+++ b/content/docs/automation/approvals.mdx
@@ -215,6 +215,43 @@ internal timer (~5 min). Without a `job` service registered, SLA timers are
**display-only** and no escalation ever fires.
+### Out-of-office delegation
+
+An approval routes to a specific person, and people take leave. Declare an
+**out-of-office delegation** so an approver's individually-routed slots reroute
+to a backup while they're away — the approval never freezes:
+
+```bash
+curl -b cookies.txt -X POST \
+ https://your-app.example.com/api/v1/data/sys_approval_delegation \
+ -H "Content-Type: application/json" \
+ -d '{
+ "delegator_id": "usr_alice",
+ "delegate_id": "usr_bob",
+ "valid_from": "2026-05-26T00:00:00Z",
+ "valid_until": "2026-05-30T00:00:00Z",
+ "reason": "Annual leave"
+ }'
+```
+
+When a request opens, approvers that resolve to a **specific individual**
+(`type: user`, `type: field`, `type: manager`) are checked against active
+delegations and rerouted to the delegate. The window is half-open
+`[valid_from, valid_until)` (UTC), evaluated **at resolution time** — no
+background job — so a lapsed delegation simply stops applying. Chains
+(A → B → C) are followed and cycle-safe. The delegate acts under **their own
+identity**; the reroute is recorded as an `ooo_substitute` audit action and both
+the delegate and the skipped approver are notified.
+
+
+**Group-routed approvers are not rerouted.** `type: position` / `team` /
+`department` / `org_membership_level` keep their whole membership, so there is
+nothing to skip; a position-holder's leave is handled by position delegation
+(ADR-0091), not here. Out-of-office delegation is self-service — create your own
+row via the data API (Setup → Approvals → *Delegations (OOO)*). For a single
+in-flight request, `reassign` hands one slot to another user directly.
+
+
### Error codes
| Code | HTTP | Meaning |
diff --git a/docs/PLATFORM_GAPS_FROM_TEMPLATES.md b/docs/PLATFORM_GAPS_FROM_TEMPLATES.md
index 5821e681e2..01cad6cefc 100644
--- a/docs/PLATFORM_GAPS_FROM_TEMPLATES.md
+++ b/docs/PLATFORM_GAPS_FROM_TEMPLATES.md
@@ -29,7 +29,7 @@
| 17 | 🟡 P2 | 批量 | 批量操作 UI 入口存在但未端到端验证 | 大数据集运营卡顿 |
| 18 | 🟡 P2 | i18n | zh-CN ↔ English 切换偶发错乱 | 国内客户体验差 |
| 19 | 🟢 P3 | 企业 | SSO / SAML / Okta 未在模板中验证 | 中大客户准入门槛 |
-| 20 | 🟢 P3 | 企业 | 多步审批、委派、不在岗代理缺失 | 同上 |
+| 20 | 🟢 P3 | 企业 | ~~多步审批、委派、不在岗代理缺失~~ 委派 / OOO 社区版部分已修复;代办访问·治理归企业版 | 同上 |
| 21 | 🟢 P3 | 企业 | 外部审计员 / 只读访客门户缺失 | 合规类应用刚需 |
| 22 | 🟢 P3 | 移动 | 移动端响应式未验证 | 审批走手机是基本盘 |
| 23 | 🟢 P3 | 文档 | ~~`services.data.{get,update}` 等 API 未文档化~~ | Fixed in Unreleased: 新增 `content/docs/kernel/runtime-services/*` |
@@ -275,10 +275,18 @@
平台是否支持企业身份接入?模板里只用了本地账号。建议补一个 reference integration + 文档。
-### 20. 多步审批、委派、不在岗代理
+### 20. ~~多步审批、委派、不在岗代理~~
见 #5 的延伸——这是企业版的"准入条件"。
+**Partially fixed(社区版部分,#1322):** 委派 / 不在岗(OOO)的"审批不冻结"连续性底线已落在开源核心 `plugin-approvals`:
+
+- **不在岗自动跳过(M1)**:新增自助对象 `sys_approval_delegation`(`{delegator_id, delegate_id, valid_from, valid_until, reason}`)。`ApprovalService.expandApprovers` 解析到具体个人(`type: user` / `field` / `manager`)时,命中生效中的委派即按链路改派给候补人(复用 `isGrantActive` 的半开时间窗,解析时判定,无后台任务;带环/自引用保护)。职位路径的休假仍由 ADR-0091 职务代理覆盖。
+- **临时委派(M2)**:沿用既有 `ApprovalService.reassign`(`POST /approvals/requests/:id/reassign` + 客户端 `approvals.reassign`),单任务转交并留审计。
+- **审计 + 通知(M4)**:改派写 `sys_approval_action`(`action: 'ooo_substitute'`,记 `A → B — reason`)并通知候补人与被跳过人。
+
+**仍属企业版(objectstack-ai/cloud#855):** 代办访问 / act-as 收件箱、委派治理 + 职责分离(SoD)、组织级代管他人控制台、合规报表 / 认证复核。多步 / 并行审批见 #5(P1),与本条分开推进。
+
### 21. 外部审计员 / 只读访客门户
合规类应用必备:外部审计员需要只读访问指定证据集合,不能给完整账号。建议引入"受限链接 / 临时访客角色"。
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index ddeaaa909c..ad4b1eb63d 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -2515,6 +2515,25 @@ export class ObjectStackClient {
return this.unwrapResponse(res);
},
+ /**
+ * Hand a pending-approver slot to someone else (#1322 M2 — self-service
+ * task delegation). `to` is the new approver; `from` defaults to the
+ * caller. Records a `reassign` audit action and notifies the new approver.
+ * (Standing out-of-office delegation is CRUD on `sys_approval_delegation`
+ * via the generic data API, so it needs no dedicated helper here.)
+ */
+ reassign: async (
+ requestId: string,
+ input: { to: string; from?: string; actorId?: string; comment?: string },
+ ): Promise<{ request: ApprovalRequestRow }> => {
+ const route = this.getRoute('approvals');
+ const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/reassign`, {
+ method: 'POST',
+ body: JSON.stringify({ to: input.to, from: input.from, actorId: input.actorId, comment: input.comment })
+ });
+ return this.unwrapResponse<{ request: ApprovalRequestRow }>(res);
+ },
+
/**
* Audit trail (the immutable action log) for an approval request.
*/
diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts
index 7e78aa68ab..5f9df404a8 100644
--- a/packages/plugins/plugin-approvals/src/approval-service.test.ts
+++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts
@@ -1076,3 +1076,150 @@ describe('record-lock hook (node era)', () => {
expect(engine._hooks['beforeUpdate']).toHaveLength(0);
});
});
+
+// ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
+//
+// When a resolved individual approver has declared an active OOO delegation,
+// the slot is rerouted to the delegate at resolution time (never a background
+// job), audited as `ooo_substitute`, and both parties are notified. Group /
+// graph approvers (position/team/department/tier) are left untouched.
+describe('ApprovalService — out-of-office delegation (#1322)', () => {
+ // Mid-window instant for the issue's own example (leave 5/26–5/30).
+ const OOO_NOW = new Date('2026-05-27T10:00:00Z').getTime();
+ let engine: ReturnType;
+ let svc: ApprovalService;
+ let emitted: any[];
+
+ function seedDelegation(rows: Array>) {
+ engine._tables['sys_approval_delegation'] = rows.map((r, i) => ({
+ id: `del${i}`,
+ organization_id: 't1',
+ valid_from: '2026-05-26T00:00:00Z',
+ valid_until: '2026-05-30T00:00:00Z',
+ reason: 'Annual leave',
+ ...r,
+ }));
+ }
+
+ beforeEach(() => {
+ engine = makeFakeEngine();
+ emitted = [];
+ svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(OOO_NOW) } });
+ svc.attachMessaging({ emit: async (m: any) => { emitted.push(m); } });
+ });
+
+ it('type:user — reroutes an out-of-office approver to the delegate', async () => {
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
+ expect(req.pending_approvers).toEqual(['bob']);
+ });
+
+ it('records an ooo_substitute audit action with "A → B — reason"', async () => {
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
+ await svc.openNodeRequest(openInput(['alice']), CTX);
+ const sub = engine._tables['sys_approval_action'].find((a: any) => a.action === 'ooo_substitute');
+ expect(sub).toBeTruthy();
+ expect(sub.comment).toBe('alice → bob — Annual leave');
+ expect(sub.actor_id).toBeNull(); // system-recorded reroute, no human actor
+ });
+
+ it('notifies both the delegate and the skipped approver', async () => {
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
+ await svc.openNodeRequest(openInput(['alice']), CTX);
+ const to = emitted.find(e => e.topic === 'approval.ooo_substituted');
+ const from = emitted.find(e => e.topic === 'approval.ooo_skipped');
+ expect(to?.audience).toEqual(['bob']);
+ expect(from?.audience).toEqual(['alice']);
+ });
+
+ it('does not reroute before valid_from (window not yet open)', async () => {
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_from: '2026-05-28T00:00:00Z' }]);
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
+ expect(req.pending_approvers).toEqual(['alice']);
+ expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
+ });
+
+ it('does not reroute at/after valid_until (half-open window)', async () => {
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_until: '2026-05-27T10:00:00Z' }]);
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
+ expect(req.pending_approvers).toEqual(['alice']);
+ });
+
+ it('type:field — reroutes the user stored in the record field', async () => {
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
+ const input = {
+ ...openInput([]),
+ record: { id: 'opp1', reviewer: 'alice' },
+ config: { approvers: [{ type: 'field', value: 'reviewer' }], behavior: 'first_response', lockRecord: true },
+ };
+ const req = await svc.openNodeRequest(input as any, CTX);
+ expect(req.pending_approvers).toEqual(['bob']);
+ });
+
+ it('type:manager — reroutes when the resolved manager is out of office', async () => {
+ engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }];
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
+ const input = {
+ ...openInput([]),
+ record: { id: 'opp1', owner_id: 'carol' },
+ config: { approvers: [{ type: 'manager', value: 'owner_id' }], behavior: 'first_response', lockRecord: true },
+ };
+ const req = await svc.openNodeRequest(input as any, CTX);
+ expect(req.pending_approvers).toEqual(['bob']);
+ });
+
+ it('follows a delegation chain A → B → C', async () => {
+ seedDelegation([
+ { delegator_id: 'alice', delegate_id: 'bob' },
+ { delegator_id: 'bob', delegate_id: 'carol' },
+ ]);
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
+ expect(req.pending_approvers).toEqual(['carol']);
+ expect(engine._tables['sys_approval_action'].filter((a: any) => a.action === 'ooo_substitute')).toHaveLength(2);
+ });
+
+ it('stops on a cycle A → B → A without looping', async () => {
+ seedDelegation([
+ { delegator_id: 'alice', delegate_id: 'bob' },
+ { delegator_id: 'bob', delegate_id: 'alice' },
+ ]);
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
+ expect(req.pending_approvers).toEqual(['bob']);
+ });
+
+ it('ignores a self-delegation (A → A)', async () => {
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'alice' }]);
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
+ expect(req.pending_approvers).toEqual(['alice']);
+ expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
+ });
+
+ it('leaves approvers unchanged when there is no active delegation', async () => {
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
+ expect(req.pending_approvers).toEqual(['alice']);
+ });
+
+ it('does not OOO-substitute group-routed (position) approvers', async () => {
+ engine._tables['sys_user_position'] = [{ id: 'up1', user_id: 'alice', position: 'sales_manager', organization_id: 't1' }];
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
+ const input = {
+ ...openInput([]),
+ config: { approvers: [{ type: 'position', value: 'sales_manager' }], behavior: 'first_response', lockRecord: true },
+ };
+ const req = await svc.openNodeRequest(input as any, CTX);
+ // Position-routed leave is ADR-0091's job, not this path: the holder stays.
+ expect(req.pending_approvers).toEqual(['alice']);
+ });
+
+ it('respects tenant scope: a rule scoped to another org does not apply', async () => {
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: 't2' }]);
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
+ expect(req.pending_approvers).toEqual(['alice']);
+ });
+
+ it('applies a cross-tenant (null org) rule regardless of request tenant', async () => {
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: null }]);
+ const req = await svc.openNodeRequest(openInput(['alice']), CTX);
+ expect(req.pending_approvers).toEqual(['bob']);
+ });
+});
diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts
index dcfce07cae..d7cc02e1f4 100644
--- a/packages/plugins/plugin-approvals/src/approval-service.ts
+++ b/packages/plugins/plugin-approvals/src/approval-service.ts
@@ -21,6 +21,7 @@ import type {
ApprovalStatus,
SharingExecutionContext,
} from '@objectstack/spec/contracts';
+import { isGrantActive } from '@objectstack/core';
/**
* Node-era approval runtime (ADR-0019).
@@ -100,6 +101,23 @@ export type ActionTokenOutcome =
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
+/**
+ * Max hops when following an OOO delegation chain (#1322 M1): A out → B, B out
+ * → C, … Bounds the walk so a mis-configured chain can't loop or resolve
+ * unboundedly; a cycle or self-reference also stops it early.
+ */
+const OOO_MAX_CHAIN = 8;
+
+/** One OOO delegation hop applied while resolving an approver (#1322 M1/M4). */
+interface OooSubstitution {
+ /** The approver who was skipped (out of office). */
+ from: string;
+ /** The delegate the slot was routed to. */
+ to: string;
+ /** The delegator's declared reason, if any. */
+ reason: string | null;
+}
+
function uid(prefix: string): string {
const g: any = globalThis as any;
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
@@ -296,9 +314,24 @@ export class ApprovalService implements IApprovalService {
*
* `role` is accepted as the deprecated spelling of `org_membership_level`
* (ADR-0090 D3) for one window: it resolves identically and logs a warning.
+ *
+ * **Out-of-office (#1322 M1):** individually-routed approvers — the ones that
+ * resolve to a specific person (`user` / `field` / `manager`) — are passed
+ * through {@link ApprovalService.applyOooDelegation}, which reroutes them onto
+ * an active delegate when the resolved user has declared OOO. Group/graph
+ * approvers (`team` / `department` / `position` / `org_membership_level`) are
+ * left untouched: a group still has its other members, and position-routed
+ * leave is already covered by ADR-0091 job delegation. Pass an `opts.now` /
+ * `opts.substitutions` collector to record the hops for audit + notification.
*/
- private async expandApprovers(step: any, record?: any, organizationId?: string | null): Promise {
+ private async expandApprovers(
+ step: any,
+ record?: any,
+ organizationId?: string | null,
+ opts?: { now?: number; substitutions?: OooSubstitution[] },
+ ): Promise {
if (!step || !Array.isArray(step.approvers)) return [];
+ const now = opts?.now ?? this.clock.now().getTime();
const out: string[] = [];
for (const a of step.approvers) {
if (!a) continue;
@@ -314,8 +347,14 @@ export class ApprovalService implements IApprovalService {
{ deprecated: a.type, canonical: type },
);
}
- if (type === 'user') { out.push(String(a.value)); continue; }
- if (type === 'field' && record) { out.push(String((record as any)[a.value] ?? '')); continue; }
+ if (type === 'user') {
+ for (const u of await this.applyOooDelegation(String(a.value), now, organizationId, opts?.substitutions)) out.push(u);
+ continue;
+ }
+ if (type === 'field' && record) {
+ for (const u of await this.applyOooDelegation(String((record as any)[a.value] ?? ''), now, organizationId, opts?.substitutions)) out.push(u);
+ continue;
+ }
try {
if (type === 'team') {
const users = await this.expandTeamUsers(String(a.value));
@@ -333,7 +372,10 @@ export class ApprovalService implements IApprovalService {
const subject = (record as any)[a.value] ?? (record as any).owner_id;
if (subject) {
const mgr = await this.lookupManager(String(subject));
- if (mgr) { out.push(mgr); continue; }
+ if (mgr) {
+ for (const u of await this.applyOooDelegation(mgr, now, organizationId, opts?.substitutions)) out.push(u);
+ continue;
+ }
}
}
} catch { /* fall through */ }
@@ -458,6 +500,74 @@ export class ApprovalService implements IApprovalService {
} catch { return null; }
}
+ /**
+ * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
+ * id, follow any active `sys_approval_delegation` chain and return the id the
+ * slot should actually go to — the delegate acts under their own identity, so
+ * no impersonation is involved. Returns `[userId]` unchanged when there is no
+ * active delegation. Each hop is appended to `collector` (when supplied) so
+ * the caller can audit + notify (M4).
+ *
+ * The chain (A out → B, B out → C, …) is bounded by {@link OOO_MAX_CHAIN} and
+ * stops on a self-reference or a cycle, so a mis-declared loop degrades to the
+ * last reachable delegate rather than hanging.
+ */
+ private async applyOooDelegation(
+ userId: string,
+ now: number,
+ organizationId?: string | null,
+ collector?: OooSubstitution[],
+ ): Promise {
+ const start = String(userId ?? '').trim();
+ if (!start) return [];
+ let current = start;
+ const visited = new Set([current]);
+ for (let hop = 0; hop < OOO_MAX_CHAIN; hop++) {
+ const del = await this.lookupActiveDelegation(current, now, organizationId);
+ if (!del) break;
+ const to = String(del.delegate_id ?? '').trim();
+ if (!to || to === current || visited.has(to)) break; // no-op / self / cycle
+ collector?.push({ from: current, to, reason: del.reason != null ? String(del.reason) : null });
+ visited.add(to);
+ current = to;
+ }
+ return [current];
+ }
+
+ /**
+ * The active OOO delegation for a delegator at `now`, or null. Validity is the
+ * shared `isGrantActive` half-open window (ADR-0091 D2), enforced here at
+ * resolution time — never by a background job. When several rows are active,
+ * the one expiring soonest wins (the most specific coverage window).
+ */
+ private async lookupActiveDelegation(
+ delegatorId: string,
+ now: number,
+ organizationId?: string | null,
+ ): Promise {
+ if (!delegatorId) return null;
+ let rows: any[] = [];
+ try {
+ rows = await this.engine.find('sys_approval_delegation', {
+ filter: { delegator_id: delegatorId },
+ fields: ['id', 'delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason', 'organization_id'],
+ limit: 50,
+ context: SYSTEM_CTX,
+ } as any);
+ } catch { return null; } // table absent on minimal stacks — no OOO, resolve as-is
+ const active = (rows ?? []).filter((r: any) =>
+ isGrantActive(r, now)
+ // A null-org rule applies across tenants; a scoped rule only within its tenant.
+ && (organizationId == null || r.organization_id == null || String(r.organization_id) === String(organizationId)));
+ if (!active.length) return null;
+ active.sort((a: any, b: any) => {
+ const au = a.valid_until ? Date.parse(String(a.valid_until)) : Number.POSITIVE_INFINITY;
+ const bu = b.valid_until ? Date.parse(String(b.valid_until)) : Number.POSITIVE_INFINITY;
+ return au - bu;
+ });
+ return active[0];
+ }
+
/** Mirror a request status onto a business-object field, if configured. */
private async mirrorStatusField(object: string, recordId: string, field: string, status: string): Promise {
try {
@@ -512,9 +622,15 @@ export class ApprovalService implements IApprovalService {
}
const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? input.organizationId ?? null;
- const approvers = await this.expandApprovers({ approvers: input.config.approvers }, input.record, ctxOrg);
+ const nowDate = this.clock.now();
+ // OOO auto-skip (#1322 M1): reroute individually-routed approvers who are
+ // out of office. Collected hops drive the audit + notification below (M4).
+ const substitutions: OooSubstitution[] = [];
+ const approvers = await this.expandApprovers(
+ { approvers: input.config.approvers }, input.record, ctxOrg, { now: nowDate.getTime(), substitutions },
+ );
- const now = this.clock.now().toISOString();
+ const now = nowDate.toISOString();
const id = uid('areq');
const processName = `flow:${input.flowName ?? input.nodeId}`;
// Display labels ride the config snapshot (no schema migration needed);
@@ -558,6 +674,42 @@ export class ApprovalService implements IApprovalService {
actor_id: input.submitterId ?? context.userId ?? null, comment: null, created_at: now,
}, { context: SYSTEM_CTX });
+ // OOO substitution audit + notification (#1322 M4). Each hop that rerouted
+ // an approver away from an out-of-office user is recorded on the request's
+ // audit trail (a system action, no human actor) and notified to both the
+ // delegate — who now owns the slot — and the skipped approver.
+ for (const sub of substitutions) {
+ await this.engine.insert('sys_approval_action', {
+ id: uid('aact'), request_id: id, organization_id: ctxOrg,
+ step_name: input.nodeId, step_index: 0, action: 'ooo_substitute',
+ actor_id: null,
+ comment: `${sub.from} → ${sub.to}${sub.reason ? ` — ${sub.reason}` : ''}`,
+ created_at: now,
+ }, { context: SYSTEM_CTX });
+ await this.notify({
+ topic: 'approval.ooo_substituted',
+ audience: [sub.to],
+ source: { object: 'sys_approval_request', id },
+ dedupKey: `approval-ooo-${id}-${sub.to}`,
+ payload: {
+ title: 'Approval routed to you (out-of-office cover)',
+ message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
+ actionUrl: '/system/approvals',
+ },
+ });
+ await this.notify({
+ topic: 'approval.ooo_skipped',
+ audience: [sub.from],
+ source: { object: 'sys_approval_request', id },
+ dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
+ payload: {
+ title: 'Approval routed to your delegate',
+ message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
+ actionUrl: '/system/approvals',
+ },
+ });
+ }
+
// Record lock (when `lockRecord !== false`) is enforced by the beforeUpdate
// hook keyed on the now-pending request; no extra write needed here.
if (input.config.approvalStatusField) {
diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts
index 89c7268971..8d1ade49dd 100644
--- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts
+++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts
@@ -5,6 +5,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js';
import { SysApprovalAction } from './sys-approval-action.object.js';
import { SysApprovalApprover } from './sys-approval-approver.object.js';
import { SysApprovalToken } from './sys-approval-token.object.js';
+import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
import {
ApprovalService,
@@ -70,7 +71,7 @@ export class ApprovalsServicePlugin implements Plugin {
scope: 'system',
defaultDatasource: 'cloud',
namespace: 'sys',
- objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
+ objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
// ships their menu too; when the plugin isn't installed the slot is empty.
@@ -82,6 +83,7 @@ export class ApprovalsServicePlugin implements Plugin {
items: [
{ id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
{ id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' },
+ { id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' },
],
},
],
diff --git a/packages/plugins/plugin-approvals/src/index.ts b/packages/plugins/plugin-approvals/src/index.ts
index dfbcbb7ebe..e84a1c90bb 100644
--- a/packages/plugins/plugin-approvals/src/index.ts
+++ b/packages/plugins/plugin-approvals/src/index.ts
@@ -13,6 +13,7 @@
export { SysApprovalRequest } from './sys-approval-request.object.js';
export { SysApprovalAction } from './sys-approval-action.object.js';
export { SysApprovalApprover } from './sys-approval-approver.object.js';
+export { SysApprovalDelegation } from './sys-approval-delegation.object.js';
export {
ApprovalService,
type ApprovalEngine,
diff --git a/packages/plugins/plugin-approvals/src/nav-contribution.test.ts b/packages/plugins/plugin-approvals/src/nav-contribution.test.ts
index ce63c33303..1608e14b92 100644
--- a/packages/plugins/plugin-approvals/src/nav-contribution.test.ts
+++ b/packages/plugins/plugin-approvals/src/nav-contribution.test.ts
@@ -28,6 +28,7 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', ()
expect(manifest.objects.map((o: any) => o.name).sort()).toEqual([
'sys_approval_action',
'sys_approval_approver',
+ 'sys_approval_delegation',
'sys_approval_request',
'sys_approval_token',
]);
@@ -38,6 +39,7 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', ()
expect(contribution).toMatchObject({ app: 'setup', group: 'group_approvals' });
expect(contribution.items.map((i: any) => i.objectName).sort()).toEqual([
'sys_approval_action',
+ 'sys_approval_delegation',
'sys_approval_request',
]);
// Each entry is gated so the slot stays empty when the plugin is absent.
diff --git a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts
index dbc711de3d..6596917563 100644
--- a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts
+++ b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts
@@ -92,8 +92,9 @@ export const SysApprovalAction = ObjectSchema.create({
// Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
// remind / request_info / comment are thread interactions — they never
// move the flow. revise / resubmit (ADR-0044) DO move it: send back for
- // revision and the later resubmission.
- ['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment', 'revise', 'resubmit'],
+ // revision and the later resubmission. ooo_substitute (#1322 M1) is a
+ // system-recorded reroute of an out-of-office approver — no flow movement.
+ ['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment', 'revise', 'resubmit', 'ooo_substitute'],
{
label: 'Action',
required: true,
diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts
new file mode 100644
index 0000000000..64670309e3
--- /dev/null
+++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts
@@ -0,0 +1,137 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { ObjectSchema, Field } from '@objectstack/spec/data';
+
+/**
+ * sys_approval_delegation — self-service out-of-office (OOO) delegation (#1322 M1).
+ *
+ * A standing, self-declared rule: "while I (the delegator) am out between
+ * `valid_from` and `valid_until`, route the approver slots that would resolve
+ * to me onto my delegate instead." The approval service consults active rows
+ * in `ApprovalService.expandApprovers` when resolving an approval node's
+ * INDIVIDUALLY-routed approvers (`type: user` / `field` / `manager`) — the
+ * delegate becomes a real pending approver and acts under their own identity,
+ * so nothing is impersonated and the audit trail stays honest.
+ *
+ * Modelled as its own object (not a scalar on the better-auth-locked
+ * `sys_user`), mirroring the `sys_user_position` delegation precedent
+ * (ADR-0091): the validity window is enforced at RESOLUTION time via the
+ * shared `isGrantActive` predicate — never by a background job (ADR-0049).
+ * The window is half-open `[valid_from, valid_until)` in UTC.
+ *
+ * Scope note: this is the community-core OOO auto-skip only. Long-term proxy
+ * "act-as" access (viewing/acting on another user's full queue under their
+ * authority), delegation governance / segregation-of-duties, and org-wide
+ * administration of others' delegations are enterprise concerns tracked
+ * separately (objectstack-ai/cloud#855), not here.
+ *
+ * @namespace sys
+ */
+export const SysApprovalDelegation = ObjectSchema.create({
+ name: 'sys_approval_delegation',
+ label: 'Approval Delegation',
+ pluralLabel: 'Approval Delegations',
+ icon: 'user-clock',
+ isSystem: true,
+ managedBy: 'system',
+ description:
+ 'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).',
+ titleFormat: '{delegator_id} → {delegate_id}',
+ highlightFields: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until'],
+
+ listViews: {
+ active: {
+ type: 'grid',
+ name: 'active',
+ label: 'Active',
+ data: { provider: 'object', object: 'sys_approval_delegation' },
+ columns: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason'],
+ sort: [{ field: 'valid_until', order: 'asc' }],
+ pagination: { pageSize: 50 },
+ emptyState: {
+ title: 'No delegations',
+ message: 'Declare an out-of-office delegation so approvals route to a backup while you are away.',
+ },
+ },
+ },
+
+ fields: {
+ id: Field.text({ label: 'Delegation ID', required: true, readonly: true, group: 'System' }),
+
+ delegator_id: Field.lookup('sys_user', {
+ label: 'Delegator',
+ required: true,
+ group: 'Delegation',
+ description: 'The user going out of office; their individually-routed approver slots are rerouted while active.',
+ }),
+
+ delegate_id: Field.lookup('sys_user', {
+ label: 'Delegate',
+ required: true,
+ group: 'Delegation',
+ description: 'The backup who receives the delegator\'s approvals while this rule is active. Acts under their own identity.',
+ }),
+
+ valid_from: Field.datetime({
+ label: 'Valid From',
+ required: false,
+ group: 'Delegation',
+ description:
+ 'Rule is inactive before this instant. Null = active immediately. ' +
+ 'Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job.',
+ }),
+
+ valid_until: Field.datetime({
+ label: 'Valid Until',
+ required: false,
+ group: 'Delegation',
+ description:
+ 'Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires.',
+ }),
+
+ reason: Field.text({
+ label: 'Reason',
+ required: false,
+ maxLength: 500,
+ group: 'Delegation',
+ description: 'Why the delegation exists (e.g. "Annual leave 5/26–5/30"). Recorded on the substitution audit row.',
+ }),
+
+ organization_id: Field.lookup('sys_organization', {
+ label: 'Organization',
+ required: false,
+ group: 'System',
+ description: 'Tenant that owns this rule; null = applies across tenants for this delegator.',
+ }),
+
+ created_at: Field.datetime({
+ label: 'Created At',
+ defaultValue: 'NOW()',
+ readonly: true,
+ group: 'System',
+ }),
+
+ updated_at: Field.datetime({
+ label: 'Updated At',
+ defaultValue: 'NOW()',
+ readonly: true,
+ group: 'System',
+ }),
+ },
+
+ indexes: [
+ // Resolution-time lookup: "active delegations for this delegator".
+ { fields: ['delegator_id', 'organization_id'] },
+ { fields: ['delegate_id'] },
+ { fields: ['valid_until'] },
+ ],
+
+ enable: {
+ trackHistory: true,
+ searchable: true,
+ apiEnabled: true,
+ apiMethods: ['get', 'list', 'create', 'update', 'delete'],
+ trash: true,
+ mru: false,
+ },
+});
diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts
index fe4d0a7ee2..92b5c2290a 100644
--- a/packages/spec/src/contracts/approval-service.ts
+++ b/packages/spec/src/contracts/approval-service.ts
@@ -117,7 +117,9 @@ export type ApprovalActionKind =
/** ADR-0044: an approver sent the request back for revision (request finalizes `returned`). */
| 'revise'
/** ADR-0044: the submitter resubmitted after rework (the next round's request opens with its own `submit`). */
- | 'resubmit';
+ | 'resubmit'
+ /** #1322 M1: an out-of-office approver's slot was auto-rerouted to their delegate at resolution time. */
+ | 'ooo_substitute';
/** Audit row. */
export interface ApprovalActionRow {