From 66eac1fe26db27a70c801f31275e5301e22f7bde Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 03:08:30 +0000 Subject: [PATCH 1/2] feat(spec+approvals+lint): approver value bindings, retire queue authoring (#3508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The designer's approver Value cell silently degraded to free text because it sourced user/team/department candidates from the metadata registry (GET /api/v1/meta/:type), which lists no records. Declare the contract once in the spec so every designer sources it right: - spec: export APPROVER_VALUE_BINDINGS (per-type value sourcing — record lookup objects + committed field, closed enum, auto, trigger-field, unsupported), ORG_MEMBERSHIP_LEVELS, and NON_AUTHORABLE_APPROVER_TYPES; publish 'queue' in xEnumDeprecated (declared-but-unenforced: the engine has no queue branch, the slot resolves to nobody) and map 'manager' in the value xRef so designers can render its auto-resolved state. - plugin-approvals: warn when a stored queue approver is skipped at resolution time instead of dying silently into the 'queue:' literal. - lint: new approval-approver-type-unsupported warning, data-driven from APPROVER_VALUE_BINDINGS, so authoring a queue approver is called out at publish time (declared != enforced, Prime Directive #10). Queue still parses — stored flows keep loading and rendering for the deprecation window. Designer-side record lookups land in objectui. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GzRYYvxLHqzrqG2EceDKFm --- .../approval-approver-value-bindings.md | 26 +++++ .../src/validate-approval-approvers.test.ts | 20 +++- .../lint/src/validate-approval-approvers.ts | 21 ++++ .../src/approval-service.test.ts | 25 +++++ .../plugin-approvals/src/approval-service.ts | 12 ++ packages/spec/src/automation/approval.test.ts | 67 +++++++++++- packages/spec/src/automation/approval.zod.ts | 103 ++++++++++++++++-- 7 files changed, 256 insertions(+), 18 deletions(-) create mode 100644 .changeset/approval-approver-value-bindings.md diff --git a/.changeset/approval-approver-value-bindings.md b/.changeset/approval-approver-value-bindings.md new file mode 100644 index 0000000000..3c1a6d8b27 --- /dev/null +++ b/.changeset/approval-approver-value-bindings.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-approvals': patch +'@objectstack/lint': minor +--- + +feat(approvals): declare approver value bindings; retire `queue` approver authoring (#3508) + +- `@objectstack/spec` exports `APPROVER_VALUE_BINDINGS` — the single declaration of how a + designer must source each approver row's `value`: `user`/`team`/`department`/`position` + are DATA-record lookups on the system directory objects (`sys_user` / `sys_team` / + `sys_business_unit` / `sys_position`; `position` commits the machine **name**, the + others the row id), `org_membership_level` is a closed enum (`ORG_MEMBERSHIP_LEVELS`), + `manager` is auto-resolved, `field` names a trigger-object field, and `queue` is + unsupported. Also exports `NON_AUTHORABLE_APPROVER_TYPES`. +- `queue` approver type is deprecated-for-authoring: it still parses (stored flows keep + loading and rendering) but is published in `xEnumDeprecated`, so designers stop + offering it — the runtime has no queue resolution and the slot routes to nobody. The + approver `value` xRef now also maps `manager`, so designers can render its + auto-resolved state. No authored key is removed; nothing to migrate. If a flow carries + `{ type: 'queue' }`, replace it with `team` / `department` / `position` (or a concrete + `user`) until a real ownership-queue implementation lands. +- `@objectstack/plugin-approvals` now warns at resolution time when a stored `queue` + approver is skipped. +- `@objectstack/lint` adds `approval-approver-type-unsupported` (warning) for approver + types that are declared but not implemented by the runtime. diff --git a/packages/lint/src/validate-approval-approvers.test.ts b/packages/lint/src/validate-approval-approvers.test.ts index 2a18778751..261c3dc2c8 100644 --- a/packages/lint/src/validate-approval-approvers.test.ts +++ b/packages/lint/src/validate-approval-approvers.test.ts @@ -6,6 +6,7 @@ import { APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER, APPROVAL_APPROVER_TYPE_DEPRECATED, APPROVAL_APPROVER_TYPE_UNKNOWN, + APPROVAL_APPROVER_TYPE_UNSUPPORTED, APPROVAL_ESCALATION_REASSIGN_NO_TARGET, APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY, } from './validate-approval-approvers.js'; @@ -51,6 +52,20 @@ describe('validateApprovalApprovers', () => { expect(findings[0].hint).toContain("type: 'position'"); }); + // ── queue: declared-but-unenforced (#3508) ──────────────────────────────── + + it('flags a queue approver as unsupported — the runtime resolves it to nobody', () => { + const findings = validateApprovalApprovers(stackWithApprovers([ + { type: 'queue', value: 'q_west' }, + { type: 'user', value: 'u1' }, // a real route keeps the node off the empty-slate rule + ])); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(APPROVAL_APPROVER_TYPE_UNSUPPORTED); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].path).toBe('flows[0].nodes[1].config.approvers[0].type'); + expect(findings[0].message).toContain('#3508'); + }); + // ── the deprecated `role` spelling (ADR-0090 D3, #3133) ────────────────── it('flags the deprecated `role` spelling even when its value is a valid tier', () => { @@ -77,14 +92,15 @@ describe('validateApprovalApprovers', () => { expect(findings[0].hint).not.toContain('org_membership_level, value'); }); - it('accepts the position approver type and the other spec types silently', () => { + it('accepts the position approver type and the other RESOLVABLE spec types silently', () => { + // `queue` is deliberately absent: it parses but the runtime never resolves + // it, so it now draws APPROVAL_APPROVER_TYPE_UNSUPPORTED (#3508). const findings = validateApprovalApprovers(stackWithApprovers([ { type: 'position', value: 'sales_manager' }, { type: 'user', value: 'u1' }, { type: 'manager' }, { type: 'department', value: 'bu_sales' }, { type: 'field', value: 'owner_id' }, - { type: 'queue', value: 'q1' }, { type: 'team', value: 't1' }, ])); expect(findings).toEqual([]); diff --git a/packages/lint/src/validate-approval-approvers.ts b/packages/lint/src/validate-approval-approvers.ts index 3a39a2b8f3..89748554d6 100644 --- a/packages/lint/src/validate-approval-approvers.ts +++ b/packages/lint/src/validate-approval-approvers.ts @@ -38,12 +38,14 @@ import { ApproverType, APPROVAL_NODE_TYPE, DEPRECATED_APPROVER_TYPES, + APPROVER_VALUE_BINDINGS, canonicalApproverType, } from '@objectstack/spec/automation'; export const APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = 'approval-approver-not-membership-tier'; export const APPROVAL_APPROVER_TYPE_DEPRECATED = 'approval-approver-type-deprecated'; export const APPROVAL_APPROVER_TYPE_UNKNOWN = 'approval-approver-type-unknown'; +export const APPROVAL_APPROVER_TYPE_UNSUPPORTED = 'approval-approver-type-unsupported'; export const APPROVAL_ESCALATION_REASSIGN_NO_TARGET = 'approval-escalation-reassign-no-target'; export const APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY = 'approval-approvers-may-resolve-empty'; @@ -182,6 +184,25 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin `is removed in the next major.`, hint: `Author { type: '${fix}', value: '${value}' }. It resolves identically today.`, }); + } else if ( + (APPROVER_VALUE_BINDINGS as Record)[canonical]?.source === 'unsupported' + ) { + // Declared-but-unenforced (#3508): the runtime has no resolution for + // this type — the slot degrades to an inert `type:value` literal and + // the request routes to nobody. Say it at authoring time instead of + // letting the request stall silently (Prime Directive #10). + findings.push({ + severity: 'warning', + rule: APPROVAL_APPROVER_TYPE_UNSUPPORTED, + where, + path: `${path}.type`, + message: + `approver type '${type}' is declared but not implemented by the runtime (#3508) — ` + + `the slot resolves to nobody and the request stalls.`, + hint: + `Route to people the engine can expand: { type: 'team' | 'department' | 'position', ... }. ` + + `Queue approvers need a real ownership-queue implementation before they take effect.`, + }); } } diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 9ae1085cd9..f13d3a8e8d 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -1699,3 +1699,28 @@ describe('ApprovalService — listActions attachments mapping (#3266)', () => { expect(approve?.attachments).toEqual(['file_a']); }); }); + +// #3508: `queue` is declared-but-unenforced — resolveApproverSpec has no queue +// branch, so the spec value falls through to the dead `queue:` literal. +// The engine must at least WARN so operators can see the silent dead slot; +// the spec marks the type non-authorable so designers stop offering it. +describe('ApprovalService — queue approver is unresolved (#3508)', () => { + it('falls back to the dead literal and warns', async () => { + const engine = makeFakeEngine(); + const warnings: any[] = []; + let n = 0; + const svc = new ApprovalService({ + engine: engine as any, + clock: { now: () => new Date(1757000000000 + (n++) * 1000) }, + logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) }, + }); + const req = await svc.openNodeRequest( + { ...openInput([]), config: { approvers: [{ type: 'queue', value: 'q_west' }], behavior: 'first_response', lockRecord: false } }, + CTX, + ); + // No queue expansion exists: the slot is the raw `type:value` literal, + // which matches no real user id — the request routes to nobody. + expect(req.pending_approvers).toEqual(['queue:q_west']); + expect(warnings.some(([msg]) => String(msg).includes("'queue'") && String(msg).includes('#3508'))).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 40c63af663..fdc615acc6 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -474,6 +474,18 @@ export class ApprovalService implements IApprovalService { } } } catch { /* fall through */ } + // #3508: `queue` is declared-but-unenforced — there is no queue branch + // above, so a queue approver always lands here and the `queue:` slot + // routes to nobody. The spec marks it non-authorable + // (NON_AUTHORABLE_APPROVER_TYPES) so designers stop offering it; warn for + // the stored flows that still carry one, so the silent dead slot is at + // least visible to operators. + if (type === 'queue') { + this.logger?.warn?.( + `[approvals] approver type 'queue' is not implemented — the slot resolves to nobody (#3508)`, + { value: a.value }, + ); + } return [`${a.type}:${a.value}`]; } diff --git a/packages/spec/src/automation/approval.test.ts b/packages/spec/src/automation/approval.test.ts index 02ea3dc11e..d6111ea496 100644 --- a/packages/spec/src/automation/approval.test.ts +++ b/packages/spec/src/automation/approval.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest'; import { ApproverType, DEPRECATED_APPROVER_TYPES, + NON_AUTHORABLE_APPROVER_TYPES, + APPROVER_VALUE_BINDINGS, + ORG_MEMBERSHIP_LEVELS, canonicalApproverType, APPROVAL_NODE_TYPE, ApprovalDecision, @@ -45,15 +48,71 @@ describe('ApproverType', () => { // Cross-repo contract: the published node configSchema must carry // `xEnumDeprecated` on the approver type, or the Studio designer (objectui) // derives its dropdown straight from `enum` and keeps offering `role` — the - // exact trap ADR-0090 D3 retires. Renderers read this to omit deprecated - // members from pickers while still rendering a stored value. - it('publishes xEnumDeprecated on the approver type so pickers can drop `role`', () => { + // exact trap ADR-0090 D3 retires — and `queue`, which the runtime never + // resolves (#3508). Renderers read this to omit these members from pickers + // while still rendering a stored value. + it('publishes xEnumDeprecated on the approver type so pickers drop `role` and `queue`', () => { const schema = getApprovalNodeConfigJsonSchema() as any; const typeNode = schema?.properties?.approvers?.items?.properties?.type; expect(typeNode?.enum).toContain('role'); // still parses (back-compat) + expect(typeNode?.enum).toContain('queue'); // still parses (stored rows render) expect(typeNode?.enum).toContain('org_membership_level'); - expect(typeNode?.xEnumDeprecated).toEqual(Object.keys(DEPRECATED_APPROVER_TYPES)); + expect(typeNode?.xEnumDeprecated).toEqual([...NON_AUTHORABLE_APPROVER_TYPES]); expect(typeNode?.xEnumDeprecated).toContain('role'); + expect(typeNode?.xEnumDeprecated).toContain('queue'); + }); + + // #3508: queue is declared-but-unenforced. It stays parseable (stored flows + // keep loading) but is not authorable, and every deprecated spelling is + // non-authorable too. + it('marks queue non-authorable while keeping it parseable', () => { + expect(() => ApproverType.parse('queue')).not.toThrow(); + expect(NON_AUTHORABLE_APPROVER_TYPES).toContain('queue'); + for (const spelling of Object.keys(DEPRECATED_APPROVER_TYPES)) { + expect(NON_AUTHORABLE_APPROVER_TYPES).toContain(spelling); + } + }); +}); + +// #3508: the designer contract for sourcing each approver row's `value`. The +// record-backed kinds MUST match the engine's resolution semantics +// (`plugin-approvals` resolveApproverSpec / expand*Users) — these assertions +// pin the object names and stored fields the engine actually queries. +describe('APPROVER_VALUE_BINDINGS (#3508)', () => { + it('covers every ApproverType member', () => { + for (const t of ApproverType.options) { + expect(APPROVER_VALUE_BINDINGS[t]).toBeDefined(); + } + }); + + it('binds record-backed kinds to the objects and fields the engine queries', () => { + // applyOooDelegation(String(value)) — a sys_user id. + expect(APPROVER_VALUE_BINDINGS.user).toEqual({ source: 'record', object: 'sys_user', valueField: 'id' }); + // find('sys_team_member', { team_id: value }) — a sys_team id. + expect(APPROVER_VALUE_BINDINGS.team).toEqual({ source: 'record', object: 'sys_team', valueField: 'id' }); + // find('sys_business_unit', { id: value }) — a sys_business_unit id + // (deliberately NOT a `sys_department`). + expect(APPROVER_VALUE_BINDINGS.department).toEqual({ source: 'record', object: 'sys_business_unit', valueField: 'id' }); + // find('sys_user_position', { position: value }) — the position machine + // NAME, not an id (portable across environments). + expect(APPROVER_VALUE_BINDINGS.position).toEqual({ source: 'record', object: 'sys_position', valueField: 'name' }); + }); + + it('keeps the non-record kinds off the record-lookup path', () => { + expect(APPROVER_VALUE_BINDINGS.org_membership_level).toEqual({ source: 'enum', values: ORG_MEMBERSHIP_LEVELS }); + // The deprecated alias renders with the same control as its replacement. + expect(APPROVER_VALUE_BINDINGS.role).toEqual(APPROVER_VALUE_BINDINGS.org_membership_level); + expect(APPROVER_VALUE_BINDINGS.manager.source).toBe('auto'); + expect(APPROVER_VALUE_BINDINGS.field.source).toBe('trigger-field'); + expect(APPROVER_VALUE_BINDINGS.queue.source).toBe('unsupported'); + }); + + it('publishes a manager mapping in the value xRef so designers can render auto-resolution', () => { + const schema = getApprovalNodeConfigJsonSchema() as any; + const valueNode = schema?.properties?.approvers?.items?.properties?.value; + expect(valueNode?.xRef?.map?.manager).toBe('manager'); + // queue stays mapped so stored rows keep rendering during the window. + expect(valueNode?.xRef?.map?.queue).toBe('queue'); }); }); diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index 4dc85ea2cb..5dd287c2be 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -26,7 +26,14 @@ export const ApproverType = z.enum([ '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 #3508 — declared-but-unenforced: `resolveApproverSpec` in + // `plugin-approvals` has no queue branch, so a queue approver resolves to + // nobody (the dead `queue:` fallback literal). The sharing engine + // retired its `queue` recipient the same way (sys-sharing-rule). Still + // parses so stored flows keep rendering, but designers must not offer it + // (see NON_AUTHORABLE_APPROVER_TYPES). Re-admit only together with a real + // ownership-queue implementation (queue entity + membership + claim). + 'queue' ]); /** @@ -43,6 +50,73 @@ export function canonicalApproverType(type: string): string { return (DEPRECATED_APPROVER_TYPES as Record)[type] ?? type; } +/** + * Approver types that still PARSE but must not be offered for new authoring. + * Published to designers as `xEnumDeprecated` on the approver `type` (see + * {@link ApprovalNodeApproverSchema}): a stored value keeps rendering, the + * dropdown just stops handing authors the entry. Two sources: + * - the deprecated spellings in {@link DEPRECATED_APPROVER_TYPES} (ADR-0090 D3); + * - `queue`, declared-but-unenforced in the runtime (#3508) — offering it + * would advertise a capability the engine doesn't deliver (Prime + * Directive #10: declared ≠ enforced). + */ +export const NON_AUTHORABLE_APPROVER_TYPES: readonly string[] = [ + ...Object.keys(DEPRECATED_APPROVER_TYPES), + 'queue', +]; + +/** + * How a designer must source an approver row's `value`, per approver type — + * the single declaration of the "metadata registry vs data records" split + * (#3508). The engine (`plugin-approvals` `resolveApproverSpec` + + * `expand*Users`) resolves these against DATA rows in the system directory + * objects, so the matching picker is a RECORD lookup via the data API — + * `GET /api/v1/meta/user` lists metadata types, never `sys_user` rows. + */ +export type ApproverValueBinding = + /** + * `value` identifies a row of `object`; the designer stores the row's + * `valueField`. Note `position` routes by machine **name** (the engine + * filters `sys_user_position.position` by name — deliberate, names are + * portable across environments the way ids are not), and `department` + * resolves against `sys_business_unit`, not a `sys_department`. + */ + | { source: 'record'; object: string; valueField: 'id' | 'name' } + /** Closed value set — render a strict select, never free text. */ + | { source: 'enum'; values: readonly string[] } + /** + * Resolved by the engine at runtime (submitter's manager via + * `sys_user.manager_id`); `value` is optional (an advanced override naming + * the record field that holds the subject user) and normally stays empty. + */ + | { source: 'auto' } + /** `value` names a field on the flow's trigger object. */ + | { source: 'trigger-field' } + /** Declared-but-unenforced — do not offer for authoring (#3508). */ + | { source: 'unsupported' }; + +/** Org-membership tiers (`sys_member.role`: better-auth's closed set). */ +export const ORG_MEMBERSHIP_LEVELS = ['owner', 'admin', 'member'] as const; + +/** + * The per-type value bindings. `satisfies` keeps this exhaustive: adding an + * {@link ApproverType} member without declaring how its value is sourced is a + * compile error, so the designer contract can never silently lag the enum. + */ +export const APPROVER_VALUE_BINDINGS = { + user: { source: 'record', object: 'sys_user', valueField: 'id' }, + org_membership_level: { source: 'enum', values: ORG_MEMBERSHIP_LEVELS }, + // Deprecated alias — same binding as its canonical spelling so a stored + // legacy row still renders with the right control during its window. + role: { source: 'enum', values: ORG_MEMBERSHIP_LEVELS }, + position: { source: 'record', object: 'sys_position', valueField: 'name' }, + team: { source: 'record', object: 'sys_team', valueField: 'id' }, + department: { source: 'record', object: 'sys_business_unit', valueField: 'id' }, + manager: { source: 'auto' }, + field: { source: 'trigger-field' }, + queue: { source: 'unsupported' }, +} as const satisfies Record, ApproverValueBinding>; + // ========================================================================== // Approval as a Flow Node (ADR-0019, canonical) // ========================================================================== @@ -102,10 +176,11 @@ 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. + // — the exact trap ADR-0090 D3 is retiring — one click away from `position` + // — and `queue`, which the runtime never resolves (#3508). Renderers omit + // these from pickers while still rendering a stored value. type: ApproverType.meta({ - xEnumDeprecated: Object.keys(DEPRECATED_APPROVER_TYPES), + xEnumDeprecated: [...NON_AUTHORABLE_APPROVER_TYPES], }), /** * The approver reference, interpreted per `type`: a user id (`user`), a @@ -115,18 +190,21 @@ export const ApprovalNodeApproverSchema = lazySchema(() => z.object({ * `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. + // §configSchema): the concrete picker follows the sibling `type` column. + // How each kind is BACKED (a data-record lookup on a directory object, a + // closed enum, an auto-resolved value, a trigger-object field) is declared + // once in {@link APPROVER_VALUE_BINDINGS} — designers must source the + // record-backed kinds from the DATA API, not the metadata registry (#3508). + // 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. + // legacy node still renders correctly for its deprecation window. `manager` + // maps to a picker kind that renders as "auto-resolved" (no free text); + // `queue` stays mapped so stored rows keep rendering even though the type + // is no longer authorable (see NON_AUTHORABLE_APPROVER_TYPES). value: z.string().optional().meta({ - description: 'User id / membership tier / position / team / department / field / queue — per `type`', + description: 'User id / membership tier / position / team / department / field — per `type`', xRef: { kindFrom: 'type', objectSource: '$trigger', @@ -137,6 +215,7 @@ export const ApprovalNodeApproverSchema = lazySchema(() => z.object({ position: 'position', team: 'team', department: 'department', + manager: 'manager', field: 'object-field', queue: 'queue', }, From e09a77109561a48077993dfd03e2ac96387e2356 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 04:51:47 +0000 Subject: [PATCH 2/2] docs(spec): regenerate approval reference + note queue is unimplemented (#3508) The approver `value` description no longer advertises queue; regenerate the generated reference page to match, and say plainly in the approvals guide that a queue approver parses but resolves to nobody. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GzRYYvxLHqzrqG2EceDKFm --- content/docs/automation/approvals.mdx | 7 +++++-- content/docs/references/automation/approval.mdx | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index be644a178d..e7ea9e3a0a 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -207,11 +207,14 @@ Only `approvers` is required on the node; everything else has a default (`behavior: 'first_response'`, `lockRecord: true`, `maxRevisions: 3`). Approver entries resolve by kind — `position`, `user`, `field`, `manager`, -`team`, `department`, `queue`, `org_membership_level`, and `expression` +`team`, `department`, `org_membership_level`, and `expression` (described in the [callout above](#3-the-approval-node) and in [Dynamic approvers](#dynamic-approvers-3447)); `org_membership_level` is the one that silently resolves to nobody when it's mistaken for a business -hierarchy. `field`, `manager`, and `expression` resolve against the record's +hierarchy. `queue` still parses so stored flows keep loading, but it is **not +implemented** by the runtime and is no longer offered for authoring (#3508) — +a queue entry resolves to nobody. Route to a `team`, `department`, or +`position` instead. `field`, `manager`, and `expression` resolve against the record's **live** state at node entry (#3447). An entry that resolves to nobody is not an error: the request opens with an empty `pending_approvers` and nothing can move it, so the run parks forever. diff --git a/content/docs/references/automation/approval.mdx b/content/docs/references/automation/approval.mdx index 3382355cb1..f6a4e34cbb 100644 --- a/content/docs/references/automation/approval.mdx +++ b/content/docs/references/automation/approval.mdx @@ -55,7 +55,7 @@ const result = ApprovalDecision.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'user' \| 'org_membership_level' \| 'role' \| 'position' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue' \| 'expression'>` | ✅ | | -| **value** | `string` | optional | User id / membership tier / position / team / department / field / queue — per `type`; for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*` | +| **value** | `string` | optional | User id / membership tier / position / team / department / field — per `type`; for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*` | | **resolveAs** | `Enum<'user' \| 'department' \| 'position' \| 'team'>` | optional | How an `expression` result is expanded into approvers (default 'user') | | **group** | `string` | optional | Group label for per_group sign-off (e.g. "legal", "finance") |