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
53 changes: 53 additions & 0 deletions .changeset/adr-0105-d9-cross-org-approver-targeting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-approvals": minor
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(approvals): cross-organization approver targeting — a plant document can
require a group-side sign-off (ADR-0105 D9)

One organization id used to decide three different things at once in
`openNodeRequest`: where the request row lives, where its inbox index rows
live, and **where its approvers are looked up**. The first two are the
request's own organization by definition. The third is not — a group CFO holds
her `cfo` position in the GROUP organization while the purchase order she signs
off lives in the PLANT organization. `expandPositionUsers('cfo', <plant>)`
matched nobody, the slot fell back to the dead `position:cfo` literal, and a
group escalation could not be expressed at all.

An approver may now declare which organization's directory resolves it:

```yaml
approvers:
- { type: position, value: plant_manager, group: plant }
- { type: position, value: cfo, organization: $root, group: finance }
behavior: per_group
```

- **`$root` / `$parent`** walk D6's `parent_organization_id` tree, so the two
common intents need **no deployment knowledge** — flow metadata is portable
across environments while organization ids are minted per deployment. A slug
covers what the symbols cannot, notably a **sibling** organization (a
shared-services centre approving payables for every plant).
- Declared **per approver**, so one node can require a plant manager and a
group CFO in parallel. A node-level form cannot express that without
splitting into serial nodes, which changes the semantics.
- **Bounded, not free:** the target must share a `parent_organization_id` root
with the request's organization. The rule reads only the organization tree —
never the submitter — so one flow routes identically for everyone.

Everything else fails loudly rather than quietly:

- a non-`group` posture **refuses** the declaration (a `group` → `isolated`
migration must not silently reroute approvals);
- an approver type with no org-scoped directory (`user` / `field` / `manager` /
`team`) refuses it too, and a new `approval-approver-cross-org-unsupported`
lint catches that at author time;
- a targeted approver holding no membership in the request's organization is
dropped with a warning naming them — D2's union wall would otherwise hide the
request from someone already routed to, so the node's existing
`onEmptyApprovers` policy takes over instead of leaving an unopenable task.

Nothing changes for an approver without `organization`: same resolution, same
queries, no extra reads.
67 changes: 66 additions & 1 deletion content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,68 @@ submit:
with `behavior: 'per_group'`, each intermediate value (e.g. each returned
department) forms its own sign-off group.

## Approving across organizations (ADR-0105 D9) [#cross-org-approvers]

<Callout type="info">
Requires the `group` tenancy posture. Under `single` or `isolated` the runtime
**refuses** an approver that declares `organization`, rather than ignoring it.
</Callout>

A group-shaped deployment routinely needs a plant's document signed off by
someone at the group: the purchase order lives in the **plant** organization,
but the CFO holds her `cfo` position in the **group** organization. By default
an approver is resolved against the request's own organization, so `cfo` would
match nobody there.

Declare which organization's directory resolves that approver:

```typescript
config: {
approvers: [
{ type: 'position', value: 'plant_manager', group: 'plant' },
{ type: 'position', value: 'cfo', organization: '$root', group: 'finance' },
],
behavior: 'per_group', // one plant sign-off AND one group sign-off
}
```

`organization` takes a **symbol** or an organization **slug**:

| Value | Resolves to |
| :--- | :--- |
| `$root` | the group organization — climbs `parent_organization_id` to the top |
| `$parent` | exactly one level up (division sign-off in a three-tier group) |
| a slug, e.g. `acme-ssc` | that organization — for a **sibling**, such as a shared-services centre approving payables for every plant |

Prefer the symbols. Flow metadata is portable across environments while
organization ids are minted per deployment, so `$root` says "the group" without
naming anything deployment-specific. An organization **id** is rejected: pass
the slug.

Three things this deliberately does **not** do:

- **It does not move the request.** Only the approver lookup changes — the
request, its audit trail, and its inbox rows stay in the plant's
organization.
- **It is not a route to any tenant.** The target must share a
`parent_organization_id` root with the request's organization, so approval
routes *within one group*. The rule reads only the organization tree, never
the submitter, so a flow routes identically for everyone.
- **It does not apply to every approver kind.** `user`, `field`, `manager` and
`team` name people without consulting an organization directory;
`organization` on those is refused at runtime and flagged by `os lint`
(`approval-approver-cross-org-unsupported`).

<Callout type="warn">
A cross-organization approver must also be able to **read** the request. The
request stays in the plant's organization, so a group-side approver reaches it
only if she also holds a membership there — the usual group shape is that group
staff are members of every plant while holding their positions at the group.
Approvers without that membership are dropped from the slate with a warning
naming them, and the node's `onEmptyApprovers` policy takes over — better an
empty slate you can see than a task the tenancy wall hides.
</Callout>

A result may legitimately be **empty** (a present-but-empty field or variable);
the node-level `onEmptyApprovers` policy decides what that means —
`admin_rescue` (default: the request opens, a privileged admin takes over via
Expand Down Expand Up @@ -230,7 +292,10 @@ 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
**live** state at node entry (#3447). `position`, `department`,
`org_membership_level` and `expression` may additionally name which
organization's directory resolves them — see
[Approving across organizations](#cross-org-approvers). 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
3 changes: 2 additions & 1 deletion content/docs/references/automation/approval.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const result = ApprovalDecision.parse(data);
| **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") |
| **organization** | `string` | optional | ADR-0105 D9 — organization whose directory resolves this approver: `$root` (group org), `$parent` (one level up), or an organization slug. Omitted = the request's own organization. |


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

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **approvers** | `{ type: Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| 'org_membership_level' \| 'role' \| 'user' \| 'queue'>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string }[]` | ✅ | Allowed approvers for this node |
| **approvers** | `{ type: Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| 'org_membership_level' \| 'role' \| 'user' \| 'queue'>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string; … }[]` | ✅ | Allowed approvers for this node |
| **behavior** | `Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>` | ✅ | How to combine multiple approvers |
| **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Default 1 |
| **lockRecord** | `boolean` | ✅ | Lock the record from editing while pending |
Expand Down
51 changes: 45 additions & 6 deletions docs/adr/0105-group-tenancy-posture-and-first-class-org-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,14 +249,53 @@ the `single`-posture deployment (plant-autonomous member admission) and is the
natural admission UX in `group` posture.

**D9 — Cross-org approval targeting.**
An approval chain node may name a **target organization** for approver
resolution (default: the request's org, today's behavior at
`approval-node.ts:118`). A plant document's escalation step declares
`organization: <group org>` and resolves group-side position holders. Reads of
the request by those approvers are covered by D2 (membership union) in `group`
posture; in `isolated` posture this decision does not apply (cross-org
An approval **approver** may name a **target organization** for its resolution
(default: the request's org, today's behavior). A plant document's escalation
step declares `organization: $root` and resolves group-side position holders.
Reads of the request by those approvers are covered by D2 (membership union) in
`group` posture; in `isolated` posture this decision does not apply (cross-org
approval there remains mirroring via system context, cloud #2937 contract).

*Amended during implementation (#3812). Four points the original text left
open, each settled from what the code and the authoring model actually
require:*

1. *Per **approver**, not per node.* The node-level form cannot express the
commonest group shape — one node requiring a plant manager **and** a group
CFO in parallel (`behavior: 'per_group'`). Expressing it as two serial nodes
changes the semantics (parallel co-sign becomes sequential approval), so the
node-level form distorts the model rather than merely inconveniencing the
author. A node-level default is a strict special case of the per-approver
form and remains addable later as sugar; the reverse is not true.
2. *Symbolic references first: `$root`, `$parent`.* Flow metadata is portable
across environments; an organization id is data minted per deployment. A
literal id in a flow is therefore unportable by construction, and an AI
author cannot know one at authoring time. The symbols express the two common
intents against D6's tree with **zero deployment knowledge**. A slug covers
what they cannot — notably a **sibling** organization (a shared-services
centre approving payables for every plant).
3. *Legality is "shares a `parent_organization_id` root", not "is an
ancestor".* The sibling case above is first-class, as is downward targeting.
The rule depends only on the organization tree and **never on the
submitter**, so one flow routes identically for everyone — which is what
makes a routing bug reproducible. A deployment with no grouping metadata
gets a refusal naming D6, not a silent pass.
4. *Non-`group` postures **refuse** at runtime rather than ignoring.* Posture is
environment configuration, so the same portable metadata may be deployed
into any posture and no static check can see which. Silently ignoring the
declaration would let a `group` → `isolated` migration reroute approvals
with no signal — an audit event, not a config detail.

*Two consequences worth stating, both enforced:* an approver type that resolves
no org-scoped directory (`user` / `field` / `manager` / `team`) **refuses** the
declaration instead of ignoring it (an author who wrote it believed it did
something); and a targeted approver who holds no membership in the request's
organization is **dropped with a warning** naming them, because D2's union
would otherwise hide the request from someone the router had already committed
to — routing succeeds, the inbox row is written, and the approver opens a task
she cannot open. Dropping converts that silent dead-end into the node's
existing `onEmptyApprovers` policy.

**D10 — Layered master data (group template + org override).**
A spec-level pattern for the SAP material-master / 用友-金蝶 distribution
shape: an object may declare layered governance — group-level template rows
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export {
APPROVAL_EXPRESSION_INVALID,
APPROVAL_EXPRESSION_NO_EMPTY_POLICY,
APPROVAL_DECISION_OUTPUTS_RESERVED,
APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
} from './validate-approval-approvers.js';
export type { ApprovalApproverFinding, ApprovalApproverSeverity } from './validate-approval-approvers.js';

Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-approval-approvers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
APPROVAL_EXPRESSION_INVALID,
APPROVAL_EXPRESSION_NO_EMPTY_POLICY,
APPROVAL_DECISION_OUTPUTS_RESERVED,
APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
} from './validate-approval-approvers.js';

function stackWithApprovers(approvers: unknown[]): Record<string, unknown> {
Expand Down Expand Up @@ -334,3 +335,39 @@ describe('expression approvers (#3447 P2)', () => {
}))).toEqual([]);
});
});

describe('cross-organization targeting (ADR-0105 D9)', () => {
it('is clean when `organization` sits on an org-scoped approver type', () => {
// The whole point of D9: a group CFO resolved against the group directory.
const findings = validateApprovalApprovers(
stackWithApprovers([{ type: 'position', value: 'cfo', organization: '$root' }]),
);
expect(findings.filter(f => f.rule === APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED)).toEqual([]);
});

it('errors when `organization` sits on a type with no organization directory', () => {
// `user` names a person outright — the declaration cannot narrow anything,
// and the runtime refuses it. Catch it at author time instead.
const findings = validateApprovalApprovers(
stackWithApprovers([{ type: 'user', value: 'u1', organization: '$root' }]),
);
const f = findings.find(x => x.rule === APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED);
expect(f?.severity).toBe('error');
expect(f?.path).toBe('flows[0].nodes[1].config.approvers[0].organization');
expect(f?.hint).toMatch(/position.*org_membership_level.*department.*expression/);
});

it('errors for `team` too — team membership carries no organization', () => {
const findings = validateApprovalApprovers(
stackWithApprovers([{ type: 'team', value: 't1', organization: 'acme-ssc' }]),
);
expect(findings.some(f => f.rule === APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED)).toBe(true);
});

it('stays silent when no organization is declared — the default path', () => {
const findings = validateApprovalApprovers(
stackWithApprovers([{ type: 'user', value: 'u1' }]),
);
expect(findings.filter(f => f.rule === APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED)).toEqual([]);
});
});
28 changes: 28 additions & 0 deletions packages/lint/src/validate-approval-approvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
* | approval-expression-invalid | error/info | #3447 P2 closed-root expressions |
* | approval-expression-no-empty-policy | info | #3447 P2 empty-slate policy |
* | approval-decision-outputs-reserved | error | #3447 P2 resume envelope |
* | approval-approver-cross-org-unsupported | error | ADR-0105 D9 targeting |
*
* The first two are mutually exclusive by construction — a bad *value* wins,
* because its fix (`position`) differs from the deprecation's fix
Expand All @@ -42,6 +43,7 @@ import {
APPROVAL_NODE_TYPE,
DEPRECATED_APPROVER_TYPES,
APPROVER_VALUE_BINDINGS,
approverTypeIsOrgScoped,
canonicalApproverType,
normalizeDecisionOutputs,
} from '@objectstack/spec/automation';
Expand All @@ -57,6 +59,7 @@ export const APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY = 'approval-approvers-may-reso
export const APPROVAL_EXPRESSION_INVALID = 'approval-expression-invalid';
export const APPROVAL_EXPRESSION_NO_EMPTY_POLICY = 'approval-expression-no-empty-policy';
export const APPROVAL_DECISION_OUTPUTS_RESERVED = 'approval-decision-outputs-reserved';
export const APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED = 'approval-approver-cross-org-unsupported';

/**
* The CLOSED root set an `expression` approver may reference (#3447 P2) —
Expand Down Expand Up @@ -304,6 +307,31 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin
`Queue approvers need a real ownership-queue implementation before they take effect.`,
});
}

// [ADR-0105 D9] Cross-organization targeting on a type that has no
// organization-scoped directory. `user` / `field` / `manager` name a
// person outright and `team` membership carries no organization, so the
// declaration cannot narrow anything — it is a misunderstanding of what
// the field does, and the runtime refuses it. Error, not warning: this
// is a certain authoring mistake with a certain fix, and letting it
// reach the runtime turns author time into an incident.
const declaredOrg = (a as AnyRec).organization;
if (typeof declaredOrg === 'string' && declaredOrg.trim() !== ''
&& ApproverType.options.includes(canonical as never)
&& !approverTypeIsOrgScoped(canonical)) {
findings.push({
severity: 'error',
rule: APPROVAL_APPROVER_CROSS_ORG_UNSUPPORTED,
where,
path: `${path}.organization`,
message:
`approver type '${type}' does not resolve through an organization directory, so ` +
`'organization: ${declaredOrg}' has no effect (ADR-0105 D9) — the runtime refuses it.`,
hint:
`Drop 'organization' here. Cross-organization targeting applies to ` +
`'position', 'org_membership_level', 'department' and 'expression' approvers.`,
});
}
}

// Empty-slate dead-end (#3424): when EVERY approver on the node routes to
Expand Down
Loading
Loading