Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/approval-approver-value-bindings.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/approval.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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") |

Expand Down
20 changes: 18 additions & 2 deletions packages/lint/src/validate-approval-approvers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
APPROVAL_EXPRESSION_INVALID,
Expand Down Expand Up @@ -54,6 +55,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', () => {
Expand All @@ -80,14 +95,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([]);
Expand Down
21 changes: 21 additions & 0 deletions packages/lint/src/validate-approval-approvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ import {
ApproverType,
APPROVAL_NODE_TYPE,
DEPRECATED_APPROVER_TYPES,
APPROVER_VALUE_BINDINGS,
canonicalApproverType,
} from '@objectstack/spec/automation';
import { collectCelRootIdentifiers } from '@objectstack/formula';

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';
export const APPROVAL_EXPRESSION_INVALID = 'approval-expression-invalid';
Expand Down Expand Up @@ -269,6 +271,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<string, { source: string }>)[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.`,
});
}
}

Expand Down
25 changes: 25 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1937,3 +1937,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:<id>` 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);
});
});
12 changes: 12 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,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:<id>` 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}`];
}

Expand Down
5 changes: 5 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -1972,6 +1972,8 @@
"./automation": [
"APPROVAL_BRANCH_LABELS (const)",
"APPROVAL_NODE_TYPE (const)",
"APPROVER_EXPRESSION_ROOTS (const)",
"APPROVER_VALUE_BINDINGS (const)",
"ActionCategory (type)",
"ActionCategorySchema (const)",
"ActionDescriptor (type)",
Expand All @@ -1989,6 +1991,7 @@
"ApprovalNodeConfig (type)",
"ApprovalNodeConfigSchema (const)",
"ApproverType (const)",
"ApproverValueBinding (type)",
"AuthField (type)",
"AuthFieldSchema (const)",
"Authentication (type)",
Expand Down Expand Up @@ -2097,10 +2100,12 @@
"LoopConfigParsed (type)",
"LoopConfigSchema (const)",
"MappableFlow (interface)",
"NON_AUTHORABLE_APPROVER_TYPES (const)",
"NodeExecutorDescriptor (type)",
"NodeExecutorDescriptorSchema (const)",
"OAuth2Config (type)",
"OAuth2ConfigSchema (const)",
"ORG_MEMBERSHIP_LEVELS (const)",
"OS_CONSTRUCT_EXT (const)",
"OperationParameter (type)",
"OperationParameterSchema (const)",
Expand Down
67 changes: 63 additions & 4 deletions packages/spec/src/automation/approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
});
});

Expand Down
Loading