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/position-approver-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-approvals': minor
'@objectstack/lint': minor
'@objectstack/cli': patch
---

Add a `position` approver type so approvals can route to org positions (ADR-0090 D3 fallout).

Post ADR-0090 D3 the `role` approver type resolves against the better-auth org-membership
tier (`sys_member.role`: `owner`/`admin`/`member`) — it was never a position. Downstream
apps that authored `{ type: 'role', value: 'sales_manager' }` silently routed approvals to
nobody. Now:

- **spec**: `ApproverType` gains `'position'` — `value` is the position machine name; the
approver expands to its holders via `sys_user_position`. Authoring guidance: keep
`type: 'role'` ONLY for membership tiers; for org positions use
`{ type: 'position', value: '<position_name>' }` (one-line fix for the mismatch above).
- **plugin-approvals**: the engine resolves `position` approvers via `sys_user_position` ∪
the `sys_member.role` transition source (same semantics as `PositionGraphService` in
plugin-sharing). The `department` approver type is now honored by its spec spelling
(previously only the off-spec `business_unit`/`bu` dialect matched).
- **lint**: new `validateApprovalApprovers` rule — `approval-role-not-membership-tier`
warns when a `role` approver's value is not a membership tier and prescribes the
`position` rewrite; `approval-approver-type-unknown` flags off-spec approver types
(with a `business_unit` → `department` fix-it). Wired into `os lint`.
14 changes: 11 additions & 3 deletions content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ A schedule-triggered escalation has no triggering user — so it must be `system

### 3. The approval node

The node declares **who approves** (a named user, a role, the submitter's manager, …) and what happens on approve / reject / escalate. The authoritative shape is [`ApprovalNodeConfig` / `ApproverType`](/docs/references/automation/approval); a flow strings the trigger, the approval node, and the post-decision branches together.
The node declares **who approves** (a named user, a position, the submitter's manager, …) and what happens on approve / reject / escalate. The authoritative shape is [`ApprovalNodeConfig` / `ApproverType`](/docs/references/automation/approval); a flow strings the trigger, the approval node, and the post-decision branches together.

```ts
// Illustrative — see the Approval reference for the exact node schema.
Expand All @@ -40,7 +40,7 @@ defineFlow({
{ id: 'start', type: 'start', label: 'Start',
config: { objectName: 'invoice', triggerType: 'record-after-create' } },
{ id: 'approval', type: 'approval', label: 'Approval',
config: { approvers: [{ type: 'role', value: 'finance_manager' }] } },
config: { approvers: [{ type: 'position', value: 'finance_manager' }] } },
{ id: 'mark_approved', type: 'update_record', label: 'Mark Approved' },
{ id: 'mark_rejected', type: 'update_record', label: 'Mark Rejected' },
],
Expand All @@ -52,7 +52,15 @@ defineFlow({
});
```

Approving is itself a gated action — model "may approve" as a capability (`approve_invoice`) the approver role holds, and gate the approve action's `requiredPermissions` on it so the gate is enforced on **both** the UI and the server (ADR-0066 D4).
<Callout type="warn">
**`position` vs `role`.** `{ type: 'position', value: 'finance_manager' }` routes to the
holders of a position (`sys_user_position`, ADR-0090 D3). The `role` approver type is the
better-auth **org-membership tier** (`sys_member.role`: `owner`/`admin`/`member`) — a position
name authored as `type: 'role'` matches nobody and the request stalls; `os lint` flags this
(`approval-role-not-membership-tier`).
</Callout>

Approving is itself a gated action — model "may approve" as a capability (`approve_invoice`) the approver's permission set grants, and gate the approve action's `requiredPermissions` on it so the gate is enforced on **both** the UI and the server (ADR-0066 D4).

## Approval nodes in practice

Expand Down
6 changes: 3 additions & 3 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const approvalFlow = {
id: 'request_approval',
type: 'approval',
label: 'Manager Approval',
config: { approvers: [{ type: 'role', value: 'manager' }] },
config: { approvers: [{ type: 'position', value: 'manager' }] },
},
{ id: 'end', type: 'end', label: 'End' },
],
Expand Down Expand Up @@ -366,8 +366,8 @@ resumes down `approve`. Any one rejection finalizes immediately down `reject`.
label: 'Finance + Legal Sign-off',
config: {
approvers: [
{ type: 'role', value: 'finance' },
{ type: 'role', value: 'legal' },
{ type: 'position', value: 'finance' },
{ type: 'position', value: 'legal' },
],
behavior: 'unanimous', // 'first_response' = any one decides
lockRecord: false,
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/automation/approval.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ const result = ApprovalDecision.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'role' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue'>` | ✅ | |
| **value** | `string` | optional | User id / role / team / department / field / queue — per `type` |
| **type** | `Enum<'user' \| 'role' \| 'position' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue'>` | ✅ | |
| **value** | `string` | optional | User id / membership tier / position / team / department / field / queue — per `type` |


---
Expand All @@ -82,6 +82,7 @@ const result = ApprovalDecision.parse(data);

* `user`
* `role`
* `position`
* `team`
* `department`
* `manager`
Expand Down
16 changes: 8 additions & 8 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export const BudgetApprovalFlow = defineFlow({
type: 'approval',
label: 'Manager Review',
config: {
approvers: [{ type: 'role', value: 'manager' }],
approvers: [{ type: 'position', value: 'manager' }],
behavior: 'first_response',
lockRecord: true,
// ADR-0044: at most two send-backs; the third auto-rejects.
Expand All @@ -206,7 +206,7 @@ export const BudgetApprovalFlow = defineFlow({
type: 'approval',
label: 'Executive Review',
config: {
approvers: [{ type: 'role', value: 'exec' }],
approvers: [{ type: 'position', value: 'exec' }],
behavior: 'unanimous',
lockRecord: true,
},
Expand Down Expand Up @@ -562,7 +562,7 @@ export const ClosureSignoffSubflow = defineFlow({
type: 'approval',
label: 'Manager Sign-off',
config: {
approvers: [{ type: 'role', value: 'manager' }],
approvers: [{ type: 'position', value: 'manager' }],
behavior: 'first_response',
// The parent project just hit a terminal status — no point locking it.
lockRecord: false,
Expand Down Expand Up @@ -875,8 +875,8 @@ export const ResilientSyncFlow = defineFlow({
*
* Decide via the approvals API (never a raw engine `resume`):
* POST /api/v1/automation/showcase_invoice_signoff/runs/{runId}/... ← no
* POST /api/v1/approvals/requests/{id}/approve { actorId: 'role:finance' }
* POST /api/v1/approvals/requests/{id}/approve { actorId: 'role:legal' } ← now it continues
* POST /api/v1/approvals/requests/{id}/approve { actorId: 'position:finance' }
* POST /api/v1/approvals/requests/{id}/approve { actorId: 'position:legal' } ← now it continues
*/
export const InvoiceDualSignoffFlow = defineFlow({
name: 'showcase_invoice_signoff',
Expand Down Expand Up @@ -905,8 +905,8 @@ export const InvoiceDualSignoffFlow = defineFlow({
config: {
// Two approver groups, notified in parallel; `unanimous` waits for both.
approvers: [
{ type: 'role', value: 'finance' },
{ type: 'role', value: 'legal' },
{ type: 'position', value: 'finance' },
{ type: 'position', value: 'legal' },
],
behavior: 'unanimous',
// The invoice keeps flowing through other automations while it waits.
Expand Down Expand Up @@ -1063,7 +1063,7 @@ export const OneTaskSignoffSubflow = defineFlow({
type: 'approval',
label: 'Task Sign-off',
config: {
approvers: [{ type: 'role', value: 'manager' }],
approvers: [{ type: 'position', value: 'manager' }],
behavior: 'first_response',
lockRecord: false,
},
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
import { computeI18nCoverage } from '../utils/i18n-coverage.js';
import { lintDataModel } from '../lint/data-model-rules.js';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture } from '@objectstack/lint';
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateApprovalApprovers } from '@objectstack/lint';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { scoreMetadata } from '../lint/score.js';
import { runMetadataEval } from '../lint/metadata-eval.js';
Expand Down Expand Up @@ -369,6 +369,21 @@ export function lintConfig(config: any): LintIssue[] {
});
}

// ── Approval-node approvers (ADR-0090 D3 fallout) ──
// `{ type: 'role' }` resolves against the better-auth org-membership tier
// (owner/admin/member), NOT positions — a position name authored there
// silently routes the approval to nobody. Advisory: the fix-it points at
// `{ type: 'position' }` (sys_user_position).
for (const t of validateApprovalApprovers(config)) {
issues.push({
severity: t.severity === 'info' ? 'suggestion' : t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

return issues;
}

Expand Down
7 changes: 7 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ export {
} from './validate-capability-references.js';
export type { CapabilityRefFinding, CapabilityRefSeverity } from './validate-capability-references.js';

export {
validateApprovalApprovers,
APPROVAL_ROLE_NOT_MEMBERSHIP_TIER,
APPROVAL_APPROVER_TYPE_UNKNOWN,
} from './validate-approval-approvers.js';
export type { ApprovalApproverFinding, ApprovalApproverSeverity } from './validate-approval-approvers.js';

export {
validateSecurityPosture,
SECURITY_OWD_UNSET,
Expand Down
89 changes: 89 additions & 0 deletions packages/lint/src/validate-approval-approvers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import {
validateApprovalApprovers,
APPROVAL_ROLE_NOT_MEMBERSHIP_TIER,
APPROVAL_APPROVER_TYPE_UNKNOWN,
} from './validate-approval-approvers.js';

function stackWithApprovers(approvers: unknown[]): Record<string, unknown> {
return {
flows: [{
name: 'expense_approval',
nodes: [
{ id: 'start', type: 'start', config: {} },
{ id: 'step1', type: 'approval', config: { approvers } },
],
edges: [],
}],
};
}

describe('validateApprovalApprovers', () => {
it('is clean on an empty / flow-less stack', () => {
expect(validateApprovalApprovers({})).toEqual([]);
expect(validateApprovalApprovers({ flows: [] })).toEqual([]);
});

it('accepts membership tiers for type role (owner/admin/member/guest)', () => {
const findings = validateApprovalApprovers(stackWithApprovers([
{ type: 'role', value: 'admin' },
{ type: 'role', value: 'Owner' }, // case-insensitive
{ type: 'role', value: 'member' },
{ type: 'role', value: 'guest' },
]));
expect(findings).toEqual([]);
});

it("flags a position name authored as type 'role' (the ADR-0090 D3 hotcrm class)", () => {
const findings = validateApprovalApprovers(stackWithApprovers([
{ type: 'role', value: 'sales_manager' },
]));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(APPROVAL_ROLE_NOT_MEMBERSHIP_TIER);
expect(findings[0].severity).toBe('warning');
expect(findings[0].where).toContain('expense_approval');
expect(findings[0].path).toBe('flows[0].nodes[1].config.approvers[0].value');
expect(findings[0].hint).toContain("type: 'position'");
});

it('accepts the position approver type and the other spec types silently', () => {
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([]);
});

it('flags off-spec approver types, with a canonical fix for the business_unit dialect', () => {
const findings = validateApprovalApprovers(stackWithApprovers([
{ type: 'business_unit', value: 'bu_sales' },
{ type: 'group', value: 'g1' },
]));
expect(findings).toHaveLength(2);
expect(findings[0].rule).toBe(APPROVAL_APPROVER_TYPE_UNKNOWN);
expect(findings[0].hint).toContain("type: 'department'");
expect(findings[1].rule).toBe(APPROVAL_APPROVER_TYPE_UNKNOWN);
});

it('only scans approval nodes and tolerates malformed shapes', () => {
const findings = validateApprovalApprovers({
flows: [{
name: 'f',
nodes: [
{ id: 'a', type: 'script', config: { approvers: [{ type: 'role', value: 'sales_manager' }] } },
{ id: 'b', type: 'approval' }, // no config
{ id: 'c', type: 'approval', config: { approvers: 'oops' } },
null,
],
}, null, 'garbage'],
} as never);
expect(findings).toEqual([]);
});
});
Loading