@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +863 -0
  2. package/dist/index.d.mts +2236 -2688
  3. package/dist/index.d.ts +2236 -2688
  4. package/dist/index.js +591 -128
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +590 -127
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +17 -10
  9. package/.turbo/turbo-build.log +0 -22
  10. package/scripts/i18n-extract.config.ts +0 -38
  11. package/src/action-link-pages.ts +0 -102
  12. package/src/approval-actor-impersonation.test.ts +0 -330
  13. package/src/approval-node.test.ts +0 -356
  14. package/src/approval-node.ts +0 -196
  15. package/src/approval-revise.test.ts +0 -418
  16. package/src/approval-service.test.ts +0 -2858
  17. package/src/approval-service.ts +0 -3617
  18. package/src/approvals-plugin.ts +0 -294
  19. package/src/approver-cross-org.integration.test.ts +0 -206
  20. package/src/approver-org-scope.test.ts +0 -201
  21. package/src/approver-org-scope.ts +0 -261
  22. package/src/index.ts +0 -42
  23. package/src/lifecycle-hooks.ts +0 -201
  24. package/src/nav-contribution.test.ts +0 -50
  25. package/src/record-lock-schedule-run.integration.test.ts +0 -206
  26. package/src/status-mirror-cascade.integration.test.ts +0 -224
  27. package/src/sys-approval-action.object.ts +0 -149
  28. package/src/sys-approval-approver.object.ts +0 -85
  29. package/src/sys-approval-delegation.object.test.ts +0 -42
  30. package/src/sys-approval-delegation.object.ts +0 -142
  31. package/src/sys-approval-request.object.test.ts +0 -116
  32. package/src/sys-approval-request.object.ts +0 -413
  33. package/src/sys-approval-token.object.ts +0 -101
  34. package/src/translations/bundle-ownership.test.ts +0 -48
  35. package/src/translations/en.objects.generated.ts +0 -311
  36. package/src/translations/es-ES.objects.generated.ts +0 -311
  37. package/src/translations/index.ts +0 -23
  38. package/src/translations/ja-JP.objects.generated.ts +0 -311
  39. package/src/translations/zh-CN.objects.generated.ts +0 -311
  40. package/tsconfig.json +0 -10
@@ -1,294 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { Plugin, PluginContext } from '@objectstack/core';
4
- import { SysApprovalRequest } from './sys-approval-request.object.js';
5
- import { SysApprovalAction } from './sys-approval-action.object.js';
6
- import { SysApprovalApprover } from './sys-approval-approver.object.js';
7
- import { SysApprovalToken } from './sys-approval-token.object.js';
8
- import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
9
- import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
10
- import {
11
- ApprovalService,
12
- ESCALATION_JOB_NAME,
13
- ESCALATION_SCAN_INTERVAL_MS,
14
- type ApprovalEngine,
15
- } from './approval-service.js';
16
- import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
17
- import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
18
-
19
- export interface ApprovalsPluginOptions {
20
- /** Disable runtime registration (schemas still register). */
21
- disableService?: boolean;
22
- /**
23
- * Interval between SLA escalation scans (ADR-0042). Defaults to
24
- * {@link ESCALATION_SCAN_INTERVAL_MS} (5 min). Only takes effect when a
25
- * `job` service is installed; without one, SLA stays display-only.
26
- */
27
- escalationScanIntervalMs?: number;
28
- /**
29
- * Absolute origin for actionable links in outbound notifications
30
- * (ADR-0043), e.g. `https://app.example.com`. Relative by default.
31
- */
32
- publicBaseUrl?: string;
33
- /**
34
- * Disable the record-lock hook. Schema + service stay intact; only the
35
- * engine-level lock wiring is suppressed. Useful when a caller wants the
36
- * manual API only (e.g. tests).
37
- */
38
- disableAutoHooks?: boolean;
39
- }
40
-
41
- /**
42
- * ApprovalsServicePlugin — registers sys_approval_{request,action}, the
43
- * `approvals` service, the `approval` flow node executor (ADR-0019), and the
44
- * record-lock hook.
45
- *
46
- * ADR-0019: approval is no longer a standalone process engine. A flow's
47
- * Approval node opens a request and suspends the run; a decision via the
48
- * service resumes it down the matching branch.
49
- */
50
- export class ApprovalsServicePlugin implements Plugin {
51
- name = 'com.objectstack.service.approvals';
52
- version = '1.0.0';
53
- type = 'standard';
54
- dependencies = ['com.objectstack.engine.objectql'];
55
-
56
- private readonly options: ApprovalsPluginOptions;
57
- private service?: ApprovalService;
58
- private engine?: any;
59
- private escalationJobScheduled = false;
60
-
61
- constructor(options: ApprovalsPluginOptions = {}) {
62
- this.options = options;
63
- }
64
-
65
- async init(ctx: PluginContext): Promise<void> {
66
- ctx.getService<{ register(m: any): void }>('manifest').register({
67
- id: 'com.objectstack.service.approvals',
68
- name: 'Approvals Service',
69
- version: '1.0.0',
70
- type: 'plugin',
71
- scope: 'system',
72
- defaultDatasource: 'cloud',
73
- namespace: 'sys',
74
- objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
75
- // ADR-0029 D7 — contribute the Approvals entries into the Setup app's
76
- // `group_approvals` slot. This plugin owns these objects (K2.b), so it
77
- // ships their menu too; when the plugin isn't installed the slot is empty.
78
- navigationContributions: [
79
- {
80
- app: 'setup',
81
- group: 'group_approvals',
82
- priority: 100,
83
- items: [
84
- { id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
85
- { id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' },
86
- { id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' },
87
- ],
88
- },
89
- ],
90
- });
91
- // ADR-0029 D8 — contribute this plugin's object translations to the i18n
92
- // service on kernel:ready (the i18n plugin may register after this one).
93
- if (typeof (ctx as any).hook === 'function') {
94
- (ctx as any).hook('kernel:ready', async () => {
95
- try {
96
- const i18n = ctx.getService<any>('i18n');
97
- if (i18n && typeof i18n.loadTranslations === 'function') {
98
- const { ApprovalsTranslations } = await import('./translations/index.js');
99
- for (const [locale, data] of Object.entries(ApprovalsTranslations)) {
100
- i18n.loadTranslations(locale, data as Record<string, unknown>);
101
- }
102
- }
103
- } catch { /* i18n optional */ }
104
- });
105
- }
106
- ctx.logger.info('ApprovalsServicePlugin: schemas registered');
107
- }
108
-
109
- async start(ctx: PluginContext): Promise<void> {
110
- if (this.options.disableService) return;
111
- let engine: any = null;
112
- try { engine = ctx.getService<any>('objectql'); }
113
- catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }
114
- if (!engine) {
115
- ctx.logger.warn('ApprovalsServicePlugin: no ObjectQL engine — service NOT registered');
116
- return;
117
- }
118
- this.engine = engine;
119
-
120
- this.service = new ApprovalService({
121
- engine: engine as ApprovalEngine,
122
- logger: ctx.logger,
123
- publicBaseUrl: this.options.publicBaseUrl,
124
- // [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
125
- // capability. Read LAZILY (not captured at start) because the tenancy
126
- // service resolves its posture during its own start, which may not have
127
- // run yet; an unresolvable posture reads as "unknown" and the guard
128
- // stands down rather than refusing a legitimate flow on a minimal stack.
129
- tenancyPosture: () => {
130
- try {
131
- const tenancy = ctx.getService<{ posture?: string }>('tenancy');
132
- const posture = tenancy?.posture;
133
- return typeof posture === 'string' && posture ? posture : undefined;
134
- } catch {
135
- return undefined;
136
- }
137
- },
138
- });
139
-
140
- // Record lock: block edits to a record while it has a pending request.
141
- // Delegation write-guard: a self-service OOO delegation may only name the
142
- // acting user as delegator (#1322 follow-up). Both bind under the same
143
- // package id, so unbindAllHooks clears them together.
144
- if (!this.options.disableAutoHooks) {
145
- try {
146
- unbindAllHooks(engine);
147
- bindApprovalLockHook(engine, ctx.logger);
148
- bindDelegationWriteGuard(engine, ctx.logger);
149
- } catch (err: any) {
150
- ctx.logger.warn?.('[approvals] failed to bind approval hooks', { error: err?.message });
151
- }
152
- }
153
-
154
- ctx.registerService('approvals', this.service);
155
- ctx.logger.info('ApprovalsServicePlugin: service registered');
156
-
157
- // Optional messaging service (ADR-0012): thread interactions (reassign /
158
- // remind / request-info / comment) notify users when present; without it
159
- // they degrade to audit-only.
160
- try {
161
- const messaging = ctx.getService<any>('messaging');
162
- if (messaging && typeof messaging.emit === 'function') {
163
- this.service.attachMessaging(messaging);
164
- }
165
- } catch { /* messaging not installed */ }
166
-
167
- // SLA escalation clock (ADR-0042): a plugin-internal job, deliberately
168
- // NOT a flow trigger (ADR-0041 §1). Interval sweep + one catch-up scan at
169
- // boot so a restart doesn't extend a breach by a scan period. Wired on
170
- // kernel:ready — the job service may start after this plugin. No `job`
171
- // service → SLA stays display-only.
172
- const wireEscalationClock = async () => {
173
- try {
174
- const jobs = ctx.getService<any>('job');
175
- if (!jobs || typeof jobs.schedule !== 'function' || !this.service) return;
176
- const svc = this.service;
177
- const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
178
- // Both sweeps ride this one clock: they walk the same `pending` set, and
179
- // the dead-run release (#3456) is reconciliation with the same "catch up
180
- // after a restart" requirement — a run killed BY the restart is exactly
181
- // the shape no in-band handler can clean up.
182
- // Genuinely independent — an escalation failure must not strand locked
183
- // records, and vice versa, so neither can short-circuit the other.
184
- const sweep = async () => {
185
- const results = await Promise.allSettled([
186
- svc.runEscalations(),
187
- svc.releaseDeadRunRequests(),
188
- ]);
189
- for (const r of results) {
190
- if (r.status === 'rejected') {
191
- ctx.logger.warn?.('[approvals] periodic sweep leg failed', {
192
- error: (r.reason as any)?.message ?? String(r.reason),
193
- });
194
- }
195
- }
196
- };
197
- await jobs.schedule(ESCALATION_JOB_NAME, { type: 'interval', intervalMs }, sweep);
198
- this.escalationJobScheduled = true;
199
- void sweep().catch((err: any) => {
200
- ctx.logger.warn?.('[approvals] boot sweep failed', { error: err?.message });
201
- });
202
- ctx.logger.info('ApprovalsServicePlugin: SLA escalation scan scheduled', { intervalMs });
203
- } catch { /* job service not installed */ }
204
- };
205
- // Actionable-link pages (ADR-0043): session-less confirm + redemption,
206
- // mounted straight on the host Hono app. GET only renders; the decision
207
- // happens exclusively on the POST (mail-gateway prefetch safe).
208
- const mountActionPages = async () => {
209
- try {
210
- const http = ctx.getService<any>('http-server');
211
- const rawApp = http && typeof http.getRawApp === 'function' ? http.getRawApp() : null;
212
- if (!rawApp || !this.service) return;
213
- const svc = this.service;
214
- const ACT_PATH = '/api/v1/approvals/act';
215
- const html = (c: any, body: string, status = 200) =>
216
- c.body(body, status, { 'Content-Type': 'text/html; charset=utf-8' });
217
- rawApp.get(ACT_PATH, async (c: any) => {
218
- const token = String(c.req.query('token') ?? '');
219
- const peek = await svc.peekActionToken(token);
220
- if (!peek.ok) return html(c, renderResultPage(peek.reason, peek.request), 200);
221
- return html(c, renderConfirmPage({
222
- request: peek.request, action: peek.action, approverId: peek.approverId,
223
- token, actPath: ACT_PATH,
224
- }));
225
- });
226
- rawApp.post(ACT_PATH, async (c: any) => {
227
- let token = '';
228
- try {
229
- const body = await c.req.parseBody();
230
- token = String(body?.token ?? '');
231
- } catch { /* fall through to invalid */ }
232
- const out = await svc.redeemActionToken(token);
233
- if (!out.ok) return html(c, renderResultPage(out.reason, out.request), 200);
234
- return html(c, renderResultPage(out.action === 'approve' ? 'approved' : 'rejected', out.request));
235
- });
236
- ctx.logger.info(`ApprovalsServicePlugin: actionable-link pages mounted at ${ACT_PATH}`);
237
- } catch { /* http server not installed */ }
238
- };
239
-
240
- // Pending-approver index backfill (issue #1745): rebuild the normalized
241
- // sys_approval_approver rows from the pending_approvers CSV so requests
242
- // written before the index existed (or drifted past a crashed sync) are
243
- // queryable. Idempotent; cost tracks the live pending queue.
244
- const backfillApproverIndex = async () => {
245
- try {
246
- const svc = this.service;
247
- if (!svc) return;
248
- const out = await svc.rebuildApproverIndex();
249
- if (out.inserted > 0 || out.deleted > 0) {
250
- ctx.logger.info('ApprovalsServicePlugin: approver index rebuilt', out);
251
- }
252
- } catch (err: any) {
253
- ctx.logger.warn?.('[approvals] approver index backfill failed', { error: err?.message });
254
- }
255
- };
256
-
257
- if (typeof (ctx as any).hook === 'function') {
258
- (ctx as any).hook('kernel:ready', wireEscalationClock);
259
- (ctx as any).hook('kernel:ready', mountActionPages);
260
- (ctx as any).hook('kernel:ready', backfillApproverIndex);
261
- } else {
262
- await wireEscalationClock();
263
- await mountActionPages();
264
- await backfillApproverIndex();
265
- }
266
-
267
- // ADR-0019: contribute the `approval` node to the flow engine when one is
268
- // present. The node lets a flow suspend on an approval and resume on
269
- // decision; the service is wired to the same engine so `decide()` can
270
- // resume the suspended run.
271
- try {
272
- const automation = ctx.getService<ApprovalAutomationSurface>('automation');
273
- if (automation && typeof automation.registerNodeExecutor === 'function') {
274
- this.service.attachAutomation(automation);
275
- registerApprovalNode(automation, this.service, ctx.logger);
276
- }
277
- } catch {
278
- ctx.logger.info('ApprovalsServicePlugin: no automation engine — approval node not registered');
279
- }
280
- }
281
-
282
- async stop(ctx: PluginContext): Promise<void> {
283
- if (this.escalationJobScheduled) {
284
- try {
285
- const jobs = ctx.getService<any>('job');
286
- await jobs?.cancel?.(ESCALATION_JOB_NAME);
287
- } catch { /* ignore */ }
288
- this.escalationJobScheduled = false;
289
- }
290
- if (this.engine) {
291
- try { unbindAllHooks(this.engine); } catch { /* ignore */ }
292
- }
293
- }
294
- }
@@ -1,206 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * [ADR-0105 D9] Cross-organization approver targeting, through the SERVICE.
5
- *
6
- * `approver-org-scope.test.ts` owns the resolver's own rules. What only this
7
- * level can show is that the resolved organization actually reaches the
8
- * directory lookups — the wiring D9 is: one organization id used to decide
9
- * three different things at once (where the request lives, where its inbox rows
10
- * live, where its approvers are looked up), and only the third moves.
11
- *
12
- * The scenario is the one D9 exists for: a purchase order raised in PLANT A
13
- * needs the group CFO, who holds `cfo` in the GROUP organization and would have
14
- * matched nobody before.
15
- */
16
-
17
- import { describe, it, expect, beforeEach } from 'vitest';
18
- import { ApprovalService } from './approval-service.js';
19
-
20
- interface Row { [k: string]: any }
21
-
22
- function makeEngine(seed: Record<string, Row[]> = {}) {
23
- const tables: Record<string, Row[]> = { ...seed };
24
- const ensure = (n: string) => (tables[n] ??= []);
25
- const matches = (row: Row, filter: any): boolean => {
26
- if (!filter || typeof filter !== 'object') return true;
27
- for (const [k, v] of Object.entries(filter)) {
28
- if (k === '$or') {
29
- if (!(v as any[]).some((sub) => matches(row, sub))) return false;
30
- continue;
31
- }
32
- const rv = row[k];
33
- if (v != null && typeof v === 'object' && '$in' in (v as any)) {
34
- if (!(v as any).$in.includes(rv)) return false;
35
- continue;
36
- }
37
- if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
38
- if (rv === (v as any).$ne) return false;
39
- continue;
40
- }
41
- if (rv !== v) return false;
42
- }
43
- return true;
44
- };
45
- return {
46
- _tables: tables,
47
- async find(object: string, options?: any) {
48
- return ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
49
- },
50
- async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
51
- async update(_o: string, _w: any, _d: any) { return {}; },
52
- async delete() { return {}; },
53
- async count(object: string) { return ensure(object).length; },
54
- registerHook() { /* no-op */ },
55
- unregisterHooksByPackage() { /* no-op */ },
56
- };
57
- }
58
-
59
- /** Plant A sits under the group; the CFO's position lives in the group org. */
60
- const SEED = () => ({
61
- sys_organization: [
62
- { id: 'o_group', slug: 'acme-group', parent_organization_id: null },
63
- { id: 'o_plant', slug: 'acme-plant-a', parent_organization_id: 'o_group' },
64
- ],
65
- sys_user_position: [
66
- { id: 'up1', user_id: 'u_cfo', position: 'cfo', organization_id: 'o_group' },
67
- { id: 'up2', user_id: 'u_plant_mgr', position: 'plant_manager', organization_id: 'o_plant' },
68
- ],
69
- sys_member: [
70
- // The intended group shape: group staff hold a membership in every plant
71
- // (so D2's union lets them READ the request) while their POSITION lives in
72
- // the group organization.
73
- { id: 'm1', user_id: 'u_cfo', organization_id: 'o_plant', role: 'member' },
74
- { id: 'm2', user_id: 'u_plant_mgr', organization_id: 'o_plant', role: 'member' },
75
- ],
76
- });
77
-
78
- const CTX = { userId: 'u_submitter', organizationId: 'o_plant', positions: [], permissions: [] } as any;
79
-
80
- function openInput(approvers: any[], extra: Record<string, any> = {}) {
81
- return {
82
- object: 'purchase_order',
83
- recordId: 'po1',
84
- runId: 'run_1',
85
- nodeId: 'group_signoff',
86
- flowName: 'po_approval',
87
- config: { approvers, behavior: 'unanimous' as const, lockRecord: false },
88
- record: { id: 'po1', amount: 500000 },
89
- ...extra,
90
- };
91
- }
92
-
93
- describe('ADR-0105 D9 — cross-org approver targeting through ApprovalService', () => {
94
- let engine: ReturnType<typeof makeEngine>;
95
- let svc: ApprovalService;
96
- let n = 0;
97
-
98
- beforeEach(() => {
99
- engine = makeEngine(SEED());
100
- n = 0;
101
- svc = new ApprovalService({
102
- engine: engine as any,
103
- clock: { now: () => new Date(1767000000000 + (n++) * 1000) },
104
- tenancyPosture: () => 'group',
105
- });
106
- });
107
-
108
- it('without targeting, a group position matches NOBODY — the gap D9 closes', async () => {
109
- // The `cfo` position exists, but in the group org; the request is Plant A's.
110
- // Pre-D9 this was the only possible outcome, and it was silent.
111
- const req = await svc.openNodeRequest(openInput([{ type: 'position', value: 'cfo' }]), CTX);
112
- expect(req.pending_approvers).toEqual(['position:cfo']); // the dead literal slot
113
- });
114
-
115
- it('`organization: $root` resolves the CFO against the GROUP directory', async () => {
116
- const req = await svc.openNodeRequest(
117
- openInput([{ type: 'position', value: 'cfo', organization: '$root' }]),
118
- CTX,
119
- );
120
- expect(req.pending_approvers).toEqual(['u_cfo']);
121
- });
122
-
123
- it('the request and its inbox rows still belong to the REQUEST org — only the lookup moved', async () => {
124
- await svc.openNodeRequest(
125
- openInput([{ type: 'position', value: 'cfo', organization: '$root' }]),
126
- CTX,
127
- );
128
- // This is the whole point of the split: targeting must not relocate the
129
- // request into the approver's organization, or the plant would lose its own
130
- // audit trail to the group.
131
- expect(engine._tables['sys_approval_request'][0].organization_id).toBe('o_plant');
132
- expect(engine._tables['sys_approval_approver'][0].organization_id).toBe('o_plant');
133
- expect(engine._tables['sys_approval_action'][0].organization_id).toBe('o_plant');
134
- });
135
-
136
- it('mixes a plant approver and a group approver in ONE node — why targeting is per-approver', async () => {
137
- // A node-level declaration could not express this: the plant manager and the
138
- // group CFO sign off in parallel, each resolved in a different directory.
139
- const req = await svc.openNodeRequest(
140
- openInput([
141
- { type: 'position', value: 'plant_manager', group: 'plant' },
142
- { type: 'position', value: 'cfo', organization: '$root', group: 'finance' },
143
- ]),
144
- CTX,
145
- );
146
- expect(new Set(req.pending_approvers)).toEqual(new Set(['u_plant_mgr', 'u_cfo']));
147
- });
148
-
149
- it('drops a targeted approver who could not READ the request, leaving the empty-slate path', async () => {
150
- // Same flow, but the CFO holds no membership in Plant A — D2's union wall
151
- // would hide the request from her. Better an empty slate (which the node has
152
- // an `onEmptyApprovers` policy for) than a task she cannot open.
153
- engine._tables['sys_member'] = engine._tables['sys_member'].filter((m) => m.user_id !== 'u_cfo');
154
- const req = await svc.openNodeRequest(
155
- openInput([{ type: 'position', value: 'cfo', organization: '$root' }]),
156
- CTX,
157
- );
158
- expect(req.pending_approvers).toEqual(['position:cfo']);
159
- });
160
-
161
- // Regression: `user` / `field` / `manager` return EARLY in
162
- // `resolveApproverSpec`, before the graph-expansion branch. D9's resolution
163
- // originally sat after those returns, so the declaration on a directory-less
164
- // type was silently INERT rather than refused — the one behaviour ADR-0105 D9
165
- // and the authoring docs both promise it is not. The resolver's own unit test
166
- // could not see it (it calls the resolver directly); only a request opened
167
- // through the service reaches the early return. Caught by cloud's
168
- // group-posture dogfood.
169
- it('refuses `organization` on a directory-less type — the early return must not skip the check', async () => {
170
- for (const spec of [
171
- { type: 'user', value: 'u_cfo', organization: '$root' },
172
- { type: 'field', value: 'owner_id', organization: '$root' },
173
- { type: 'manager', organization: '$root' },
174
- ]) {
175
- await expect(
176
- svc.openNodeRequest(openInput([spec]), CTX),
177
- `approver type '${spec.type}' must refuse a cross-org declaration`,
178
- ).rejects.toThrow(/VALIDATION_FAILED.*no effect/);
179
- }
180
- });
181
-
182
- it('a directory-less approver with NO declaration is untouched by D9', async () => {
183
- // The guard must not cost the ordinary case anything.
184
- const req = await svc.openNodeRequest(openInput([{ type: 'user', value: 'u_plant_mgr' }]), CTX);
185
- expect(req.pending_approvers).toEqual(['u_plant_mgr']);
186
- });
187
-
188
- it('refuses a target outside the group — loudly, and no request is created', async () => {
189
- engine._tables['sys_organization'].push({ id: 'o_rival', slug: 'rival-co', parent_organization_id: null });
190
- await expect(
191
- svc.openNodeRequest(openInput([{ type: 'position', value: 'cfo', organization: 'rival-co' }]), CTX),
192
- ).rejects.toThrow(/VALIDATION_FAILED.*not in the same group/);
193
- expect(engine._tables['sys_approval_request'] ?? []).toHaveLength(0);
194
- });
195
-
196
- it('refuses under a non-group posture — a posture migration cannot silently reroute', async () => {
197
- const isolated = new ApprovalService({
198
- engine: engine as any,
199
- clock: { now: () => new Date(1767000000000) },
200
- tenancyPosture: () => 'isolated',
201
- });
202
- await expect(
203
- isolated.openNodeRequest(openInput([{ type: 'position', value: 'cfo', organization: '$root' }]), CTX),
204
- ).rejects.toThrow(/VALIDATION_FAILED.*'group' tenancy posture/);
205
- });
206
- });