Skip to content

Commit 0f8ad09

Browse files
authored
feat(spec)+fix(approvals): approver value sources on the wire, author-facing type order, no more silent dead slots (#3508 / #3807) (#3817)
Four follow-ups from browser-verifying the #3508 approver work end to end. 1. `APPROVER_VALUE_SOURCES` (spec) — `xRef.map` named a picker KIND but never where its rows come from, so the designer kept its own copy of the data contract and pointed every directory kind at the metadata REGISTRY, which cannot list `sys_user` / `sys_team` / `sys_business_unit` / `sys_position` rows (#3508). The binding now ships on the published JSON schema as `xRef.sources`, derived from `APPROVER_VALUE_BINDINGS` so the two cannot drift. 2. `ApproverType` order (spec) — the Studio picker derives from this enum via the published schema, so the indirect-bindings-first order objectui#2834 argued for never took effect. The enum now carries it. 3. Dead approver slots (plugin-approvals) — every graph type that expands to nobody now says so; only `queue` used to. That silence is what let #3807 hide until an approval was permanently stuck. 4. plugin-sharing's identical org scope (tests only) — unreachable today and an authorization path, so it is pinned by tests rather than widened blind.
1 parent f022c4d commit 0f8ad09

8 files changed

Lines changed: 467 additions & 26 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-approvals": patch
4+
---
5+
6+
feat(spec)+fix(approvals): publish approver value data sources, order the type enum for authors, stop silent dead approver slots (#3508 / #3807 follow-ups)
7+
8+
Four follow-ups from browser-verifying the #3508 approver work end to end.
9+
10+
**`APPROVER_VALUE_SOURCES` — the designer stops guessing where candidates live.**
11+
`xRef.map` only ever named a picker KIND (`'team'`), never where that picker's
12+
rows come from, so the designer carried its own copy of the data contract — and
13+
the first copy was wrong: every directory kind was wired to `GET
14+
/api/v1/meta/:type`, the metadata REGISTRY, which does not hold `sys_user` /
15+
`sys_team` / `sys_business_unit` / `sys_position` rows. Candidates came back
16+
empty and the control degraded to free text (#3508). The binding is now
17+
projected onto the published JSON schema as `xRef.sources` — `{ source: 'data',
18+
object, valueField }` for the record-backed kinds, the closed enum inline for
19+
`org_membership_level` — derived from `APPROVER_VALUE_BINDINGS` so the two
20+
cannot drift, and inheriting its `satisfies` exhaustiveness (a new
21+
`ApproverType` member that declares no source is a compile error). Presentation
22+
— which field to show, whether to open a people-picker, what subtitle to use —
23+
stays a renderer decision.
24+
25+
**`ApproverType` declaration order is now the authoring recommendation.**
26+
objectui#2834 argued for leading with indirect bindings and shipped that order
27+
in its own options array — which the Studio inspector never reads: it derives
28+
the picker from this enum via the published schema, so `user` still came first.
29+
The intent only takes effect if the enum carries it, so the enum now reads
30+
`manager, position, department, team, field, expression, org_membership_level,
31+
user` (deprecated `role` / `queue` still parse and stay out of every picker via
32+
`xEnumDeprecated`). Binding one specific person is the least portable choice an
33+
author can make — it breaks when the flow moves to another environment (that id
34+
does not exist there) and again when that person leaves.
35+
36+
**A graph approver that expands to nobody no longer does it in silence.**
37+
`queue` already warned (#3508); every OTHER graph type — `team`, `department`,
38+
`position`, `org_membership_level`, `manager` — fell back to the same
39+
unactionable `type:value` literal without a word. That silence is what let
40+
#3807 hide for as long as it did: the request opened with an empty slate and
41+
the first symptom was a permanently stuck approval (#3424). The fallback stays
42+
(15.x slots and substring fixtures depend on it); it now logs the type, value
43+
and organization that produced it. `user` / `field` stay quiet — they take the
44+
id they were given and never had an "expanded to nobody" state.
45+
46+
**`plugin-sharing`'s identical org scope is pinned by tests.**
47+
`BusinessUnitGraphService.orgScope` has the same strict `organization_id`
48+
equality #3807 fixed in approvals. It is unreachable today — every materialized
49+
`sys_sharing_rule` carries `organization_id = null`, so the filter is skipped —
50+
and widening an authorization path on a defect that cannot currently fire is
51+
not a change to make blind. New tests lock both the reachable paths and the
52+
divergence itself, so if sharing ever adopts the null-org=env-wide reading it
53+
is a deliberate edit to a named test rather than a silent behaviour change.

content/docs/references/automation/approval.mdx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ description: Approval protocol schemas
77

88
Approval Step Approver Type
99

10+
Declaration order is author-facing: designers derive their picker from this
11+
12+
enum, and it leads with the portable indirect bindings (`manager`,
13+
14+
`position`, `department`, `team`) — a literal `user` id comes last.
15+
1016
<Callout type="info">
1117
**Source:** `packages/spec/src/automation/approval.zod.ts`
1218
</Callout>
@@ -54,7 +60,7 @@ const result = ApprovalDecision.parse(data);
5460

5561
| Property | Type | Required | Description |
5662
| :--- | :--- | :--- | :--- |
57-
| **type** | `Enum<'user' \| 'org_membership_level' \| 'role' \| 'position' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue' \| 'expression'>` || |
63+
| **type** | `Enum<'manager' \| 'position' \| 'department' \| 'team' \| 'field' \| 'expression' \| 'org_membership_level' \| 'role' \| 'user' \| 'queue'>` || |
5864
| **value** | `string` | optional | User id / membership tier / position / team / department / field — per `type`; for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*` |
5965
| **resolveAs** | `Enum<'user' \| 'department' \| 'position' \| 'team'>` | optional | How an `expression` result is expanded into approvers (default 'user') |
6066
| **group** | `string` | optional | Group label for per_group sign-off (e.g. "legal", "finance") |
@@ -68,7 +74,7 @@ const result = ApprovalDecision.parse(data);
6874

6975
| Property | Type | Required | Description |
7076
| :--- | :--- | :--- | :--- |
71-
| **approvers** | `{ type: Enum<'user' \| 'org_membership_level' \| 'role' \| 'position' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue' \| 'expression'>; value?: string; resolveAs?: Enum<'user' \| 'department' \| 'position' \| 'team'>; group?: string }[]` || Allowed approvers for this node |
77+
| **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 |
7278
| **behavior** | `Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>` || How to combine multiple approvers |
7379
| **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Default 1 |
7480
| **lockRecord** | `boolean` || Lock the record from editing while pending |
@@ -85,16 +91,16 @@ const result = ApprovalDecision.parse(data);
8591

8692
### Allowed Values
8793

88-
* `user`
89-
* `org_membership_level`
90-
* `role`
94+
* `manager`
9195
* `position`
92-
* `team`
9396
* `department`
94-
* `manager`
97+
* `team`
9598
* `field`
96-
* `queue`
9799
* `expression`
100+
* `org_membership_level`
101+
* `role`
102+
* `user`
103+
* `queue`
98104

99105

100106
---

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2331,6 +2331,66 @@ describe('ApprovalService — queue approver is unresolved (#3508)', () => {
23312331
});
23322332
});
23332333

2334+
// #3807 follow-up: `queue` was not the only way to end up with a slot nobody
2335+
// can act on — every GRAPH approver type falls back to the same literal when
2336+
// its lookup finds no one, and that fallback used to happen in total silence.
2337+
// A stuck approval was the first symptom; the log said nothing. The literal
2338+
// stays (15.x slots and substring fixtures depend on it) — it just announces
2339+
// itself now.
2340+
describe('ApprovalService — a graph approver that expands to nobody warns (#3807)', () => {
2341+
const svcWithWarnings = (engine: any) => {
2342+
const warnings: any[] = [];
2343+
let n = 0;
2344+
const svc = new ApprovalService({
2345+
engine,
2346+
clock: { now: () => new Date(1757000000000 + (n++) * 1000) },
2347+
logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) },
2348+
});
2349+
return { svc, warnings };
2350+
};
2351+
2352+
const approverInput = (type: string, value: string) => ({
2353+
...openInput([]),
2354+
config: { approvers: [{ type, value }], behavior: 'first_response' as const, lockRecord: false },
2355+
});
2356+
2357+
it.each([
2358+
['team', 'team_gone'],
2359+
['department', 'bu_gone'],
2360+
['position', 'nobody_holds_this'],
2361+
['org_membership_level', 'member'],
2362+
])('%s: the dead literal is logged with its type, value and org', async (type, value) => {
2363+
const engine = makeFakeEngine();
2364+
const { svc, warnings } = svcWithWarnings(engine);
2365+
const req = await svc.openNodeRequest(approverInput(type, value), CTX);
2366+
2367+
expect(req.pending_approvers).toEqual([`${type}:${value}`]);
2368+
const hit = warnings.find(([msg]) => String(msg).includes('expanded to nobody'));
2369+
expect(hit, `no warning for ${type}`).toBeTruthy();
2370+
expect(String(hit[0])).toContain('#3807');
2371+
expect(hit[1]).toMatchObject({ type, value, organizationId: 't1' });
2372+
});
2373+
2374+
it('stays quiet when the graph DOES resolve someone', async () => {
2375+
const engine = makeFakeEngine();
2376+
engine._tables['sys_team_member'] = [{ id: 'tm1', team_id: 'team_ok', user_id: 'u5' }];
2377+
const { svc, warnings } = svcWithWarnings(engine);
2378+
const req = await svc.openNodeRequest(approverInput('team', 'team_ok'), CTX);
2379+
2380+
expect(req.pending_approvers).toEqual(['u5']);
2381+
expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]);
2382+
});
2383+
2384+
it('stays quiet for `user` — a literal id was never a lookup that could come back empty', async () => {
2385+
const engine = makeFakeEngine();
2386+
const { svc, warnings } = svcWithWarnings(engine);
2387+
const req = await svc.openNodeRequest(approverInput('user', 'u_unknown'), CTX);
2388+
2389+
expect(req.pending_approvers).toEqual(['u_unknown']);
2390+
expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]);
2391+
});
2392+
});
2393+
23342394
// ── File-access delegate (ADR-0104 D3 wave 2) ────────────────────────
23352395
//
23362396
// A decision attachment is OWNED by its `sys_approval_action` row, so the

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,21 @@ function actingUserId(context: SharingExecutionContext | undefined): string | nu
177177
*/
178178
const OOO_MAX_CHAIN = 8;
179179

180+
/**
181+
* Approver types resolved by QUERYING a graph rather than by taking `value`
182+
* literally (#3807). Each can legitimately come back empty — an unstaffed
183+
* position, an emptied team, a mis-pointed unit — and the caller then falls
184+
* back to a `type:value` literal that no user can act on. They are listed here
185+
* so that dead end gets one warning instead of passing in silence.
186+
*
187+
* `user` / `field` are deliberately absent: they resolve to the id they were
188+
* given without a lookup, so there is no "expanded to nobody" state to report.
189+
* `business_unit` / `bu` are the accepted dialects of `department`.
190+
*/
191+
const GRAPH_APPROVER_TYPES: ReadonlySet<string> = new Set([
192+
'team', 'department', 'business_unit', 'bu', 'position', 'org_membership_level', 'manager',
193+
]);
194+
180195
/** One OOO delegation hop applied while resolving an approver (#1322 M1/M4). */
181196
interface OooSubstitution {
182197
/** The approver who was skipped (out of office). */
@@ -757,6 +772,21 @@ export class ApprovalService implements IApprovalService {
757772
`[approvals] approver type 'queue' is not implemented — the slot resolves to nobody (#3508)`,
758773
{ value: a.value },
759774
);
775+
} else if (GRAPH_APPROVER_TYPES.has(type)) {
776+
// #3807 follow-up — every OTHER way to land here is a graph type whose
777+
// lookup produced nobody, and the literal below is a slot no user can
778+
// ever act on. That silence is what let #3807 hide: a `department`
779+
// approver pointing at a seeded (env-wide) unit resolved to
780+
// `department:<id>` on every request, the request opened with an empty
781+
// slate, and nothing in the logs said so — the first symptom was a
782+
// permanently stuck approval (#3424). The fallback itself stays (a
783+
// literal keeps 15.x slots and substring fixtures working); it just
784+
// stops being invisible.
785+
this.logger?.warn?.(
786+
`[approvals] approver '${type}:${a.value}' expanded to nobody — the slot routes to no one `
787+
+ `and the request cannot advance until someone is added or the approver is re-pointed (#3807)`,
788+
{ type, value: a.value, organizationId: organizationId ?? null },
789+
);
760790
}
761791
return [`${a.type}:${a.value}`];
762792
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* BusinessUnitGraphService — org scoping of the unit tree.
5+
*
6+
* These pin the ORG-SCOPE behaviour specifically, because it is the exact
7+
* shape that broke approvals in #3807: `orgScope()` AND-composes a strict
8+
* `organization_id = <rule org>` equality, so a unit written with no
9+
* organization at all (a seeded / file-layer / bootstrap row — a seed cannot
10+
* know the org id the runtime mints at boot) matches nothing, the seed check
11+
* fails, and the expansion returns zero members. In approvals that produced a
12+
* dead `department:<id>` approver slot; here it would produce a sharing rule
13+
* that silently grants nobody.
14+
*
15+
* It is NOT reachable today: every materialized `sys_sharing_rule` row carries
16+
* `organization_id = null` (verified on a live showcase stack), so
17+
* `expandRecipient` passes `null` and `orgScope` is skipped entirely. The
18+
* moment rules start carrying an org — a multi-tenant deployment — a BU
19+
* subtree rule against a seeded unit stops granting, and the symptom is
20+
* "the right people cannot see the record", which is far quieter than a stuck
21+
* approval.
22+
*
23+
* So this file locks BOTH sides down:
24+
* - the reachable paths (null-org rule) keep working, and
25+
* - the divergence from approvals is written down as an executable fact
26+
* rather than a comment, so flipping it is a deliberate edit to a named
27+
* test and never a silent behaviour change.
28+
*
29+
* If the platform decides null-org means "env-wide, visible to every org" for
30+
* sharing too — the way `plugin-approvals` and `sys_metadata` already read it —
31+
* the test named `[divergence]` below is the one to flip, and `orgScope` grows
32+
* the same `$or: [{ organization_id }, { organization_id: null }]` predicate.
33+
*/
34+
35+
import { describe, it, expect } from 'vitest';
36+
import { BusinessUnitGraphService } from './business-unit-graph.js';
37+
38+
interface UnitRow {
39+
id: string;
40+
parent_business_unit_id?: string | null;
41+
organization_id?: string | null;
42+
active?: boolean;
43+
}
44+
interface MemberRow { business_unit_id: string; user_id: string }
45+
46+
/**
47+
* Minimal engine over `sys_business_unit` + `sys_business_unit_member`.
48+
* Mirrors the real filter surface the service uses: plain equality, `$ne`,
49+
* `$in`, and `$or` (so a widened predicate would actually be exercised here
50+
* rather than silently ignored by the stub).
51+
*/
52+
function makeEngine(units: UnitRow[], members: MemberRow[]) {
53+
const matches = (row: any, filter: any): boolean => {
54+
if (!filter || typeof filter !== 'object') return true;
55+
for (const [k, v] of Object.entries(filter)) {
56+
if (k === '$or') {
57+
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
58+
continue;
59+
}
60+
const rv = row[k];
61+
if (v && typeof v === 'object' && '$ne' in (v as any)) {
62+
if (rv === (v as any).$ne) return false;
63+
continue;
64+
}
65+
if (v && typeof v === 'object' && '$in' in (v as any)) {
66+
if (!(v as any).$in.includes(rv)) return false;
67+
continue;
68+
}
69+
// `organization_id: null` must also match a row that simply omits the
70+
// column — that is what a NULL column reads back as.
71+
if (v === null) {
72+
if (rv != null) return false;
73+
continue;
74+
}
75+
if (rv !== v) return false;
76+
}
77+
return true;
78+
};
79+
80+
return {
81+
async find(object: string, options: any) {
82+
const filter = options?.where ?? options?.filter ?? {};
83+
if (object === 'sys_business_unit') return units.filter((u) => matches(u, filter));
84+
if (object === 'sys_business_unit_member') return members.filter((m) => matches(m, filter));
85+
return [];
86+
},
87+
} as any;
88+
}
89+
90+
/** A seeded org tree: rows written before any organization existed. */
91+
const SEEDED_UNITS: UnitRow[] = [
92+
{ id: 'bu_root', organization_id: null, active: true },
93+
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: null, active: true },
94+
];
95+
const SEEDED_MEMBERS: MemberRow[] = [
96+
{ business_unit_id: 'bu_root', user_id: 'u_root' },
97+
{ business_unit_id: 'bu_child', user_id: 'u_child' },
98+
];
99+
100+
describe('BusinessUnitGraphService — subtree expansion', () => {
101+
it('expands the unit and every descendant', async () => {
102+
const g = new BusinessUnitGraphService({ engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS) });
103+
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
104+
});
105+
106+
it('an inactive unit contributes nobody and stops the descent', async () => {
107+
const units: UnitRow[] = [
108+
{ id: 'bu_root', organization_id: null, active: false },
109+
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: null, active: true },
110+
];
111+
const g = new BusinessUnitGraphService({ engine: makeEngine(units, SEEDED_MEMBERS) });
112+
expect(await g.expandUsers('bu_root')).toEqual([]);
113+
});
114+
});
115+
116+
describe('BusinessUnitGraphService — org scoping (#3807)', () => {
117+
it('an org-less rule (today’s materialized shape) expands seeded units fine', async () => {
118+
// `expandRecipient` passes `rule.organization_id ?? null`, and every
119+
// materialized sharing rule carries null — so `orgScope` adds nothing and
120+
// the seeded tree resolves. This is the only path that runs in practice.
121+
const g = new BusinessUnitGraphService({
122+
engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS),
123+
organizationId: null,
124+
});
125+
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
126+
});
127+
128+
it('an org-scoped rule expands units belonging to that org', async () => {
129+
const units: UnitRow[] = [
130+
{ id: 'bu_root', organization_id: 'org_a', active: true },
131+
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
132+
];
133+
const g = new BusinessUnitGraphService({
134+
engine: makeEngine(units, SEEDED_MEMBERS),
135+
organizationId: 'org_a',
136+
});
137+
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
138+
});
139+
140+
it('an org-scoped rule never reaches another org’s unit', async () => {
141+
const units: UnitRow[] = [{ id: 'bu_root', organization_id: 'org_b', active: true }];
142+
const g = new BusinessUnitGraphService({
143+
engine: makeEngine(units, SEEDED_MEMBERS),
144+
organizationId: 'org_a',
145+
});
146+
expect(await g.expandUsers('bu_root')).toEqual([]);
147+
});
148+
149+
it('[divergence] an org-scoped rule does NOT see an env-wide (null-org) unit — approvals does (#3807)', async () => {
150+
// Same inputs that #3807 fixed on the approvals side. Sharing still reads
151+
// a null-org unit as "belongs to no org, therefore not mine" and grants
152+
// nobody. Unreachable today (rules are null-org), deliberate until the
153+
// platform rules on null-org semantics for AUTHORIZATION paths — widening
154+
// who can SEE a record is not a change to make on a defect that cannot
155+
// currently fire.
156+
const g = new BusinessUnitGraphService({
157+
engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS),
158+
organizationId: 'org_a',
159+
});
160+
expect(await g.expandUsers('bu_root')).toEqual([]);
161+
});
162+
});

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1986,6 +1986,7 @@
19861986
"APPROVAL_NODE_TYPE (const)",
19871987
"APPROVER_EXPRESSION_ROOTS (const)",
19881988
"APPROVER_VALUE_BINDINGS (const)",
1989+
"APPROVER_VALUE_SOURCES (const)",
19891990
"ActionCategory (type)",
19901991
"ActionCategorySchema (const)",
19911992
"ActionDescriptor (type)",

0 commit comments

Comments
 (0)