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

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 (34) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +1037 -0
  3. package/dist/index.d.mts +650 -535
  4. package/dist/index.d.ts +650 -535
  5. package/dist/index.js +1713 -207
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1711 -197
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -7
  10. package/scripts/i18n-extract.config.ts +6 -1
  11. package/src/approval-actor-impersonation.test.ts +330 -0
  12. package/src/approval-node.test.ts +160 -0
  13. package/src/approval-node.ts +57 -0
  14. package/src/approval-revise.test.ts +41 -34
  15. package/src/approval-service.test.ts +1408 -40
  16. package/src/approval-service.ts +1364 -107
  17. package/src/approvals-plugin.ts +36 -5
  18. package/src/approver-cross-org.integration.test.ts +206 -0
  19. package/src/approver-org-scope.test.ts +201 -0
  20. package/src/approver-org-scope.ts +261 -0
  21. package/src/index.ts +3 -0
  22. package/src/lifecycle-hooks.ts +22 -0
  23. package/src/record-lock-schedule-run.integration.test.ts +206 -0
  24. package/src/status-mirror-cascade.integration.test.ts +224 -0
  25. package/src/sys-approval-action.object.ts +9 -0
  26. package/src/sys-approval-delegation.object.test.ts +42 -0
  27. package/src/sys-approval-delegation.object.ts +3 -3
  28. package/src/sys-approval-request.object.test.ts +13 -0
  29. package/src/sys-approval-request.object.ts +17 -5
  30. package/src/translations/bundle-ownership.test.ts +48 -0
  31. package/src/translations/en.objects.generated.ts +111 -5
  32. package/src/translations/es-ES.objects.generated.ts +111 -5
  33. package/src/translations/ja-JP.objects.generated.ts +111 -5
  34. package/src/translations/zh-CN.objects.generated.ts +110 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1042 @@
1
1
  # @objectstack/plugin-approvals
2
2
 
3
+ ## 17.0.0-rc.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 14252d3: feat(approvals): cross-organization approver targeting — a plant document can
8
+ require a group-side sign-off (ADR-0105 D9)
9
+
10
+ One organization id used to decide three different things at once in
11
+ `openNodeRequest`: where the request row lives, where its inbox index rows
12
+ live, and **where its approvers are looked up**. The first two are the
13
+ request's own organization by definition. The third is not — a group CFO holds
14
+ her `cfo` position in the GROUP organization while the purchase order she signs
15
+ off lives in the PLANT organization. `expandPositionUsers('cfo', <plant>)`
16
+ matched nobody, the slot fell back to the dead `position:cfo` literal, and a
17
+ group escalation could not be expressed at all.
18
+
19
+ An approver may now declare which organization's directory resolves it:
20
+
21
+ ```yaml
22
+ approvers:
23
+ - { type: position, value: plant_manager, group: plant }
24
+ - { type: position, value: cfo, organization: $root, group: finance }
25
+ behavior: per_group
26
+ ```
27
+
28
+ - **`$root` / `$parent`** walk D6's `parent_organization_id` tree, so the two
29
+ common intents need **no deployment knowledge** — flow metadata is portable
30
+ across environments while organization ids are minted per deployment. A slug
31
+ covers what the symbols cannot, notably a **sibling** organization (a
32
+ shared-services centre approving payables for every plant).
33
+ - Declared **per approver**, so one node can require a plant manager and a
34
+ group CFO in parallel. A node-level form cannot express that without
35
+ splitting into serial nodes, which changes the semantics.
36
+ - **Bounded, not free:** the target must share a `parent_organization_id` root
37
+ with the request's organization. The rule reads only the organization tree —
38
+ never the submitter — so one flow routes identically for everyone.
39
+
40
+ Everything else fails loudly rather than quietly:
41
+
42
+ - a non-`group` posture **refuses** the declaration (a `group` → `isolated`
43
+ migration must not silently reroute approvals);
44
+ - an approver type with no org-scoped directory (`user` / `field` / `manager` /
45
+ `team`) refuses it too, and a new `approval-approver-cross-org-unsupported`
46
+ lint catches that at author time;
47
+ - a targeted approver holding no membership in the request's organization is
48
+ dropped with a warning naming them — D2's union wall would otherwise hide the
49
+ request from someone already routed to, so the node's existing
50
+ `onEmptyApprovers` policy takes over instead of leaving an unopenable task.
51
+
52
+ Nothing changes for an approver without `organization`: same resolution, same
53
+ queries, no extra reads.
54
+
55
+ - f92096b: fix(approvals): an approval action is recorded against the authenticated caller, never a body field (#3800)
56
+
57
+ Every mutating approvals entrypoint takes an `actorId`, and the REST routes
58
+ filled it from `body.actorId ?? body.actor_id ?? context.userId` — so the body
59
+ won. The service then authorized _that value_: `pending_approvers.includes(
60
+ input.actorId)` for a decision, `submitter_id === actorId` for a recall. It never
61
+ checked that the value named the caller.
62
+
63
+ So any authenticated user could POST `{"actorId": "<someone else>"}` and have
64
+ that person's approval recorded, the request finalized, and the owning flow run
65
+ resumed down the `approve` edge — or name a request's submitter and recall it.
66
+ With `api.requireAuth` unset the anonymous-deny never fires either, so an
67
+ unauthenticated request could do the same.
68
+
69
+ #3783 drew this line for the _data-write_ identity and called the audit-row half
70
+ "tolerable". It was not: the same unchecked string was the authorization key, so
71
+ naming someone else was not a mislabelled audit row, it was how you got through
72
+ the door.
73
+
74
+ The actor is now resolved server-side (`ApprovalService.resolveActor`) on all
75
+ nine entrypoints — `decide` / `decideNode`, `recall`, `sendBack`, `resubmit`,
76
+ `reassign`, `remind`, `requestInfo`, `comment`.
77
+
78
+ **The rule is not "`actorId` must equal `context.userId`."** A slot can
79
+ legitimately be keyed by something else: the approver resolver stores the
80
+ `type:value` literal when a graph lookup finds no holders, and the Console picks
81
+ from the caller's own identity list — user id, email, or `role:<r>`. The rule is
82
+ **"the actor must be an identity the server can prove belongs to the caller"**:
83
+
84
+ - A **system** context keeps its explicit actor. The SLA sweep's reserved
85
+ `system:sla` sentinel and the ADR-0043 action link — whose single-use hashed
86
+ token binds exactly one approver — are unchanged. They are the only callers
87
+ holding a trustworthy actor with no session behind them.
88
+ - A caller with **no identity at all** is now refused. This is the anonymous case
89
+ above.
90
+ - **No `actorId`, or one naming the caller**, resolves to the caller. This is the
91
+ common path and what the Console already sends.
92
+ - **Any other value** is accepted only when the server can prove the caller holds
93
+ it — `position:<p>` / `role:<p>` against the positions on the resolved authz
94
+ context, or the caller's own email (one lazy `sys_user` read, taken only when
95
+ nothing cheaper matched). Otherwise `FORBIDDEN`.
96
+
97
+ REST still forwards the body value; it is now a _hint_ the service validates,
98
+ which is what keeps the email and `type:value` slot cases working.
99
+
100
+ **Upgrade note.** A client that deliberately sent another user's `actorId` now
101
+ gets `403 FORBIDDEN` instead of silently succeeding. Send the action as the
102
+ acting user's own session — the field can be omitted entirely, and the caller is
103
+ used. Server-to-server callers that legitimately act for someone else should
104
+ present a system context, as the SLA sweep and the action link already do.
105
+
106
+ This also makes two existing claims true that were previously aspirational: the
107
+ approval object's declared actions say "`actorId` defaults to the caller
108
+ server-side… the service remains the authority on who may act", and
109
+ `attachViewers` documents `can_act` as mirroring "the exact authorization the
110
+ decision methods enforce".
111
+
112
+ - fb90784: fix(approvals): the status mirror names the human who caused the transition (#3783)
113
+
114
+ When an approval moves, the service writes the new status onto the business
115
+ record (`approvalStatusField`). That write is what fires the record-change flows
116
+ bound to that object — so it is the seam "when the invoice is approved, do X"
117
+ runs through. It presented a bare `{ isSystem: true }` context with **no
118
+ `userId`**, at six call sites that each know exactly who acted: a submitter
119
+ submitting, an approver approving, rejecting, sending back, recalling.
120
+
121
+ Combined with #3760 — which stopped letting a `runAs:'user'` run with no trigger
122
+ user touch data — that identity gap made the most natural approvals automation
123
+ there is unwritable in its obvious form. The cascade inherited no user, so its
124
+ data nodes were refused, and the author's only way forward was to declare
125
+ `runAs: 'system'` and take blanket elevation for a case where a perfectly good
126
+ scoped identity existed at the call site all along.
127
+
128
+ The mirror now carries the acting user. It stays `isSystem` — the record is
129
+ normally locked while its approval is live, so only a platform write can land the
130
+ status — because elevation and anonymity are separate choices, and this write
131
+ only ever needed the first. Cascades now run as the deciding user with RLS
132
+ enforced.
133
+
134
+ - **The identity is the authenticated principal, never the request body's
135
+ `actorId`.** `actorId` arrives from the caller (`body.actorId ?? context.userId`)
136
+ and is only checked against the pending approver slate, never against the
137
+ caller. That is tolerable on an audit row; promoting it to the identity of an
138
+ RLS-scoped write would have turned a mislabelled audit trail into identity
139
+ spoofing.
140
+ - **Approval-by-email-link is attributed too.** ADR-0043 action links carry no
141
+ session, so they used to decide as pure system. The single-use hashed token
142
+ binds exactly one approver and is re-checked against the live slate at
143
+ redemption — that is an authentication — so the redeemed decision now presents
144
+ that approver, and an emailed approval cascades identically to one made in the
145
+ UI.
146
+ - **The two machine-driven transitions stay user-less on purpose**: the SLA
147
+ escalation's auto-decision and the dead-run sweep. `system:sla` and
148
+ `system:dead-run` are reserved audit actors, not users, and presenting one as a
149
+ user would put a non-user in `updated_by` and in every downstream flow's
150
+ identity. A flow that wants to react to those declares `runAs:'system'` — the
151
+ honest answer, and now a deliberate one rather than an artefact.
152
+ - **Attribution only — the write is not newly org-scoped.** On an
153
+ ExecutionContext `tenantId` is a driver-scoping knob, not attribution
154
+ (ObjectQL turns it into a tenant predicate), so passing the request's org would
155
+ have silently no-op'd the mirror on a record whose org differs. The automation
156
+ engine already back-fills a run's `tenantId` from the resolved user's grants.
157
+
158
+ **Visible change:** the mirrored record's `updated_by` now names the acting user
159
+ instead of retaining its previous value — ObjectQL's audit stamping is gated on
160
+ the write context's `userId` alone, and `isSystem` buys no exemption. That is the
161
+ attribution this fix is for: the approver who set the record to `approved` is now
162
+ its last modifier.
163
+
164
+ - a6c3f38: feat(approvals): expose the pending node's `lockRecord` policy on the request row (#3814, objectui#2902)
165
+
166
+ An approval node declares `lockRecord` (default `true`), and the record-lock
167
+ `beforeUpdate` hook enforces exactly that: `lockRecord: false` and the record
168
+ stays writable for the whole time the node waits. The behavior was correct and
169
+ has been since Phase B — but it was **invisible to every client**.
170
+
171
+ `rowFromRequest` parses `node_config_json` and projects a whitelist out of it
172
+ (`__flowLabel`, `__nodeLabel`, `__round`, `escalation.timeoutHours`,
173
+ `decisionOutputs`). `lockRecord` was never in that list, and no other field on
174
+ `ApprovalRequestRow` carried the lock either. So the strongest thing a console
175
+ could learn from `GET /approvals/requests` was _"a pending request exists"_ —
176
+ from which it can only assume the record is locked.
177
+
178
+ That assumption is wrong on every opted-out node, and a flow that chains nodes
179
+ with different policies makes it visibly wrong: the same UI state renders for
180
+ "you may edit this" and "the server will reject your save with `RECORD_LOCKED`".
181
+ The console has no third option — guessing the other way would offer an edit
182
+ that dies on save.
183
+
184
+ `ApprovalRequestRow` now carries **`lock_record: boolean`**, read from the same
185
+ snapshot the hook reads, with the same `!== false` default. Present on every
186
+ service read (`openNodeRequest` / `getRequest` / `listRequests`), so the flag a
187
+ client renders and the rule the server applies cannot drift.
188
+
189
+ Additive and backward compatible — nothing to migrate. A client that wants
190
+ node-accurate lock state reads `request.lock_record`; treat `undefined` (an
191
+ older backend) as locked, which is the pre-existing behavior.
192
+
193
+ The showcase's `showcase_budget_approval` now declares `lockRecord: false` on
194
+ its single-approver Manager Review and keeps `true` on the multi-approver
195
+ Executive Review, so both policies are exercised in one flow.
196
+
197
+ - d75edb9: Approval nodes now resolve `field` / `manager` approvers against the record's **live** state at node entry, not the trigger snapshot the flow froze at submit time (#3447). An earlier step — or the approver of an earlier step — can now write the field that routes a later step's approvers, enabling dynamic routing / dynamic co-sign (e.g. a lead reviewer picking which departments co-review, then those departments resolving as parallel approvers). Graph approvers (team / position / department / tier) already resolved live; this brings the in-record types into line.
198
+
199
+ Also fixes two latent defects on the same path: a multi-select user field now fans out into one approver slot per user (previously the array was stringified to a single bogus id), and out-of-office delegation is applied per fanned-out user (previously silently skipped for multi-value fields). When the record can't be re-read (hard-deleted mid-flow, or a backend that can't serve a point read), resolution falls back to the trigger snapshot and warns rather than wedging the flow.
200
+
201
+ - 57a3bb3: fix(automation,approvals): the run-resume route is gated by the node the run is parked on (#3801)
202
+
203
+ `POST /api/v1/automation/:name/runs/:runId/resume` forwarded a caller-supplied
204
+ `{ inputs, output, branchLabel }` straight into `AutomationEngine.resume`, and
205
+ `resumeInternal` validated **machine state only** — the concurrent-resume latch,
206
+ the run exists, the flow exists, the suspended node still exists. Nothing asked
207
+ _who was calling_.
208
+
209
+ Approval nodes suspend and resume through exactly that mechanism. So a resume
210
+ carrying `branchLabel: 'approve'` walked the approve edge with **no approver
211
+ check, no `sys_approval_action` row and no status mirror** — the
212
+ `sys_approval_request` row and the run then disagreed permanently. The only
213
+ thing standing between the route and the approvals rules was convention; the
214
+ showcase spelled it out in a comment ("decide via the approvals API, never a raw
215
+ engine `resume`"), and a comment in an example is not an access control.
216
+
217
+ Removing the route was not the fix: it is load-bearing for **screen flows** —
218
+ the UI flow-runner posts `{ inputs }` there to advance a paused `screen` node.
219
+ The gate therefore keys on **what the run is parked on**:
220
+
221
+ - `ActionDescriptor.resumeAuthority` (`'any'` | `'service'`, default `'any'`) —
222
+ a pausing node declares who may continue it. `approval` declares `'service'`.
223
+ - The engine refuses a `'service'` suspension unless the signal carries
224
+ `RESUME_AUTHORITY_SERVICE` (`@objectstack/spec/contracts`), a **symbol** the
225
+ owning service stamps in-process — a JSON body can never produce one, so the
226
+ transport cannot forge it. `ApprovalService` stamps it on the tail of a
227
+ decision it has already authorized and recorded.
228
+ - The gate follows a **subflow** pause down to the child the signal would
229
+ actually reach, so resuming the parent is not a way around it.
230
+ - Refusal returns `{ success: false, code: 'forbidden' }` and the route answers
231
+ **403**. Nothing is consumed — the request stays pending and the run stays
232
+ parked, so the real decision still lands.
233
+
234
+ `screen` and `wait` pauses are unchanged, as is every path that already went
235
+ through the approvals API. What changes for consumers:
236
+
237
+ - **FROM:** finishing an approval with
238
+ `client.automation.resume(flow, runId, { branchLabel: 'approve' })`
239
+ **TO:** `client.approvals.approve(requestId, …)` (or `.reject` / `.recall`).
240
+ The old call now answers 403 and changes nothing.
241
+ - Registering your own pausing node whose continuation belongs to a service
242
+ rather than to whoever holds the run id? Declare `resumeAuthority: 'service'`
243
+ on its descriptor and stamp `RESUME_AUTHORITY_SERVICE` on the signal from that
244
+ service.
245
+
246
+ A suspension now records the node type that produced it
247
+ (`SuspendedRun.nodeType` / `sys_automation_run.node_type`), captured at suspend
248
+ time so a flow republished mid-pause cannot re-type the node out from under the
249
+ gate; rows written before this fall back to the flow definition.
250
+
251
+ - 2fa4ca1: Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities:
252
+
253
+ **`expression` approvers.** A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: `current.*` (the record's live state), `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, incl. upstream node outputs). `record` and bare field names are rejected before evaluation — on this platform `record` always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each resolved id through the same graph lookups the static types use; with `behavior: 'per_group'` each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate.
254
+
255
+ **`onEmptyApprovers` policy.** What an empty resolved slate does, node-level, for all approver types: `admin_rescue` (default — request opens for privileged takeover, the #3424 behaviour), `fail` (node fails), or `auto_approve` (skip the request, continue down the `approve` edge with `output.autoApproved = true`). To support auto-approve, the automation engine now honours `NodeExecutionResult.branchLabel` on the synchronous completion path — the field existed but was only ever consumed via resume signals.
256
+
257
+ **Decision outputs.** `decide(..., { outputs })` hands structured data from the approver to the flow: the author declares allowed keys on the node (`decisionOutputs`), approvers fill values only, and accepted outputs resume the run as `<nodeId>.<key>` variables — a later approval node's expression can read `vars.<nodeId>.picked_departments`, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; `decision`/`requestId` are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot).
258
+
259
+ Also: `collectCelRootIdentifiers` is exported from `@objectstack/formula` (shared by the new `os lint` rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as `__resolvedFrom`, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time.
260
+
261
+ - 57bab76: Typed `decisionOutputs` declarations (#3447 follow-up). A `decisionOutputs` entry may now be `{ key, label?, type: 'text' | 'user' | 'department' | 'position' | 'team', multiple? }` alongside the bare-string form — a typed entry tells the decision UI to render the matching record picker (id values; `multiple` collects an id array) instead of free text, turning "paste user ids" into "pick people". The type shapes only the input widget: the runtime whitelist works by `key` either way, via the new `normalizeDecisionOutputs` helper exported from `@objectstack/spec/automation` — the single reader of the union shape shared by the service, the request read, and `os lint`. The request read now carries `decision_output_defs` (normalized declarations) alongside the version-skew-safe `decision_outputs` key list.
262
+
263
+ ### Patch Changes
264
+
265
+ - d058594: fix(approvals): refuse `organization` on directory-less approver types instead
266
+ of silently ignoring it (ADR-0105 D9)
267
+
268
+ `user`, `field` and `manager` return EARLY in `resolveApproverSpec` — they name
269
+ a person outright rather than expanding a directory. D9's org resolution was
270
+ placed after those returns, so an `organization` declared on one of them never
271
+ reached the check: it was silently INERT.
272
+
273
+ That is the one behaviour ADR-0105 D9 rules out and the authoring docs
274
+ explicitly promise against ("`organization` on those is refused at runtime").
275
+ The `os lint` rule caught it at author time, but the runtime claim was false —
276
+ and a stored flow that predates the lint, or one assembled programmatically,
277
+ got no signal at all.
278
+
279
+ Resolution now happens at the top of `resolveApproverSpec`, above every early
280
+ return, so the refusal reaches all three types. The ordinary path is unchanged
281
+ and still costs nothing: with no `organization` declared the resolver returns
282
+ the request's organization without reading anything.
283
+
284
+ Found by cloud's group-posture dogfood driving a real `group` boot — the
285
+ resolver's own unit tests could not see it, because they call the resolver
286
+ directly and never traverse the early return.
287
+
288
+ - 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a
289
+ first-class authorization dimension.
290
+
291
+ > This release carries BREAKING spec removals (see "Enforce-or-remove" below)
292
+ > but is recorded as `minor`: every publishable package is in the Changesets
293
+ > lockstep group, so one `major` would promote the whole monorepo. Breaking
294
+ > changes ship as `minor` during the launch window — the migration notes below
295
+ > are what reach consumers in `CHANGELOG.md`.
296
+
297
+ ## Tenancy is now a spectrum (D1)
298
+
299
+ `single | group | isolated`, resolved by the `tenancy` service and selected with
300
+ the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged:
301
+ `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED`
302
+ (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot
303
+ rather than silently landing in a posture with no organization wall.
304
+
305
+ - `single` — no wall (unchanged).
306
+ - `group` — **new.** Organizations are membership boundaries over one shared
307
+ dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC
308
+ semantics). Enforced by the OPEN engine.
309
+ - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping`
310
+ probe and degraded-boot handling all unchanged.
311
+
312
+ ## Organization scope is a first-class context field (D2)
313
+
314
+ `ExecutionContext.accessible_org_ids` — every organization the caller holds a
315
+ currently-valid membership in (ADR-0091 validity windows) — is resolved once by
316
+ `resolveAuthzContext` and carried by every transport. The `group` wall reads it
317
+ directly; RLS policies may reference it as
318
+ `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set
319
+ fails the wall closed.
320
+
321
+ Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still
322
+ computed independently of the RLS compiler, AND-composed outermost, and
323
+ crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so
324
+ ADR-0095's W1/W2 invariants hold in every posture.
325
+
326
+ ## Two P0 correctness fixes (D3, D4) — behavior changes
327
+
328
+ **D3 — app-authored org-scoped RLS policies are no longer silently dropped**
329
+ (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose
330
+ `using` contained the substring `current_user.organization_id` when isolation was
331
+ inactive, which swallowed app-authored policies as well as the platform's own.
332
+ Stripping is now decided by PROVENANCE (identity against the shipped
333
+ declaration). **Upgrade impact:** in a deployment with no organization wall, an
334
+ app-authored policy referencing the active organization is now RETAINED and
335
+ fails closed (zero rows) with a one-time warning, where it previously vanished
336
+ and the object read unscoped. `getReadFilter` shared the defect, so analytics and
337
+ raw-SQL consumers were affected too. If a policy was only ever meant for
338
+ multi-org, delete it or install `@objectstack/organizations`.
339
+
340
+ **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization
341
+ boundary** (finding F2, framework#3540). Under a wall-less posture nothing
342
+ bounded the wildcard superuser bits `organization_admin` carries, so a
343
+ deployment that accumulated organizations (personal orgs on signup) made every
344
+ owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a
345
+ de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and
346
+ revokes the superseded variant whenever the posture changes. **Upgrade impact:**
347
+ in `single` posture an org owner/admin keeps full CRUD but loses the blanket
348
+ ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains
349
+ available through `admin_full_access` or an explicitly authored permission set —
350
+ it just stops being a side effect of a better-auth membership role.
351
+
352
+ ## Engine-owned organization stamping (D5)
353
+
354
+ Under any wall-enforcing posture the engine stamps `organization_id` from the
355
+ caller's active organization on an insert that omits it, and validates every
356
+ supplied value against the wall. Idempotent with the enterprise auto-stamp
357
+ (neither overwrites a supplied value). This also closes a real hole: the
358
+ pre-existing post-image check required a non-array payload, so a BULK insert
359
+ could carry a forged `organization_id` per row. One forged row now denies the
360
+ whole write.
361
+
362
+ ## Group structure, extension fields and red-line lints (D6, D7)
363
+
364
+ - `sys_organization` gains `parent_organization_id` and `sort_order` — a
365
+ **reporting dimension only**.
366
+ - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`,
367
+ `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` /
368
+ `os validate`: an RLS policy or sharing rule that walks the org tree is an
369
+ error, as is a business-unit grant on a platform-global object.
370
+ - Extension fields on better-auth-managed objects ride the existing ADR-0092
371
+ whitelist. A new guard derives better-auth's real field surface from
372
+ `getAuthTables()` at the pinned version and fails the build on any name
373
+ collision, so a library upgrade cannot silently take ownership of a column.
374
+
375
+ ## Enforce-or-remove (D11) — BREAKING
376
+
377
+ Both removals are of surface that had **zero runtime consumers**, so no
378
+ behavior changes; authoring them is now a no-op instead of a lint warning.
379
+
380
+ - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read
381
+ it. FROM → TO: a set a policy needs as `field IN (current_user.<key>)` is now
382
+ supplied by a registered membership resolver (below); a constant belongs in
383
+ the policy itself as a literal (`status = 'published'`).
384
+ - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`)
385
+ — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO:
386
+ matrix requirements are served by multi-position × business-unit anchoring; a
387
+ generalized dimension-security module will arrive with its own ADR.
388
+ - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has
389
+ merged since ADR-0056 finally has a producer: register an
390
+ `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the
391
+ `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by
392
+ construction — an unresolved key makes its policies drop out. Kernel-owned
393
+ keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be
394
+ overwritten from this seam.
395
+
396
+ ## Edition boundary (D12)
397
+
398
+ The `group` posture's enforcement primitives ship OPEN — the union wall,
399
+ `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness
400
+ fixes and the D6 lints — because the correctness of a wall is never a paid
401
+ feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing
402
+ enterprise `org-scoping` probe, so the current commercial boundary for
403
+ legal-entity isolation is unchanged by this release.
404
+
405
+ - 2ba560a: fix(plugin-approvals): give the decision actions a visual hierarchy (objectui#2762 P1-5)
406
+
407
+ The `sys_approval_request` decision actions all declared as equal-weight
408
+ buttons, so the drawer's action bar rendered five identical outlined
409
+ buttons with no emphasis on the primary path. `approval_approve` now
410
+ declares `variant: 'primary'` and `approval_reject` declares
411
+ `variant: 'danger'`, so a metadata-driven renderer highlights Approve and
412
+ styles Reject as destructive — matching the hierarchy the mobile card
413
+ already has. Pure metadata; the secondary levers stay unstyled (tertiary).
414
+
415
+ - 2dda6e7: fix(plugin-approvals): localize the declared decision-action labels (objectui#2762 P0-3)
416
+
417
+ The Approval Center's decision drawer rendered the `sys_approval_request`
418
+ declared actions with their literal metadata labels — English **Approve /
419
+ Reject / Reassign / Send back / Request info** in a zh-CN workspace, sitting
420
+ next to the same page's localized 通过 / 拒绝 inbox buttons. The plugin's
421
+ translation bundle covered fields and views but had no `_actions` node, so
422
+ the console's `_actions.<name>.label` resolution had nothing to hit.
423
+
424
+ - Re-ran `os i18n extract` against the plugin's config: the bundles now carry
425
+ `_actions` translations (label, confirmText, successMessage, param labels
426
+ and helpText) for all eight decision actions — `approval_approve`,
427
+ `approval_reject`, `approval_reassign`, `approval_send_back`,
428
+ `approval_request_info`, `approval_remind`, `approval_recall`,
429
+ `approval_resubmit` — in zh-CN, ja-JP and es-ES (en keeps the metadata
430
+ literals).
431
+ - The extract also surfaced other untranslated gaps, now filled in all three
432
+ locales: the `returned` status option, the `sys_approval_action.action`
433
+ audit options (`reassign` / `remind` / `request_info` / `comment` /
434
+ `revise` / `resubmit` / `ooo_substitute`), the `attachments` field, and the
435
+ `my_pending` / `recent` view empty states.
436
+
437
+ - 474fe39: feat(approvals): declare approver value bindings; retire `queue` approver authoring (#3508)
438
+
439
+ - `@objectstack/spec` exports `APPROVER_VALUE_BINDINGS` — the single declaration of how a
440
+ designer must source each approver row's `value`: `user`/`team`/`department`/`position`
441
+ are DATA-record lookups on the system directory objects (`sys_user` / `sys_team` /
442
+ `sys_business_unit` / `sys_position`; `position` commits the machine **name**, the
443
+ others the row id), `org_membership_level` is a closed enum (`ORG_MEMBERSHIP_LEVELS`),
444
+ `manager` is auto-resolved, `field` names a trigger-object field, and `queue` is
445
+ unsupported. Also exports `NON_AUTHORABLE_APPROVER_TYPES`.
446
+ - `queue` approver type is deprecated-for-authoring: it still parses (stored flows keep
447
+ loading and rendering) but is published in `xEnumDeprecated`, so designers stop
448
+ offering it — the runtime has no queue resolution and the slot routes to nobody. The
449
+ approver `value` xRef now also maps `manager`, so designers can render its
450
+ auto-resolved state. No authored key is removed; nothing to migrate. If a flow carries
451
+ `{ type: 'queue' }`, replace it with `team` / `department` / `position` (or a concrete
452
+ `user`) until a real ownership-queue implementation lands.
453
+ - `@objectstack/plugin-approvals` now warns at resolution time when a stored `queue`
454
+ approver is skipped.
455
+ - `@objectstack/lint` adds `approval-approver-type-unsupported` (warning) for approver
456
+ types that are declared but not implemented by the runtime.
457
+
458
+ - 0bc685a: fix(approvals): return decision attachments as file values, not "[object Object]" (#3504)
459
+
460
+ `sys_approval_action.attachments` is a `Field.file`, so the column **stores an
461
+ opaque `sys_file` id** (ADR-0104 D3 — the stored form of every media field). The
462
+ ObjectQL read path resolves that id into its expanded
463
+ `{ id, name, size, mimeType, url }` form on the way out. But `rowFromAction`
464
+ mapped the column with `.map(String)`, collapsing each expanded value to the
465
+ literal string `"[object Object]"`. Every `listActions` consumer (the approval
466
+ inbox timeline) then received garbage: the attachment chip had no filename and
467
+ its id was `"[object Object]"`, so opening it 404'd.
468
+
469
+ - `ApprovalActionRow.attachments` is now `ApprovalActionAttachment[]` — the
470
+ expanded file value plus its id, so a consumer can label and open an
471
+ attachment without needing read access to the system `sys_file` object (which
472
+ regular approvers do not have).
473
+ - Three read forms are accepted: the expanded value (the normal case), a bare id
474
+ (nothing to expand it into — storage service absent, file not committed), and
475
+ a legacy inline blob written before file-as-reference (`file_id` /
476
+ `mime_type`), until the backfill converts it. The id test reuses the
477
+ platform's `isFileIdToken`, so this and the engine's read resolver cannot
478
+ disagree about what counts as an id.
479
+ - The decision _input_ (`ApprovalDecisionInput.attachments`) is unchanged — it
480
+ still takes fileId strings, which is also exactly what the column stores. Only
481
+ the read shape changed.
482
+
483
+ - b949059: fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456)
484
+
485
+ The record lock is keyed on a **pending** `sys_approval_request`, and it could
486
+ not tell _the run that owns that request_ from _an unrelated user editing the
487
+ record_. So a flow that touched its own target record while its own approval was
488
+ still pending — a manual `resume` with no decision, or a node that writes the
489
+ record between opening the approval and the decision — died on its own
490
+ `RECORD_LOCKED`, and the record stayed locked behind the dead run. Recovery
491
+ existed (#3424 lets an admin `recall`/`reject` to release it) but nothing made it
492
+ self-healing.
493
+
494
+ Both halves are now closed.
495
+
496
+ **Prevention — the owning run may write its own record.** The automation engine
497
+ stamps `flowRunId` onto the run context at setup, alongside `runAs`, and it
498
+ travels with every data node's ObjectQL context into `ctx.provenance`. The lock
499
+ hook exempts a write whose `flowRunId` matches the pending request's `flow_run_id`.
500
+ It is keyed on run identity rather than elevation on purpose: a `runAs:'user'`
501
+ run stays fully RLS-scoped while it writes. `flowRunId` is pure provenance —
502
+ server-constructed like `isSystem`, never client-supplied, evaluated by no
503
+ security middleware, and the only write it permits is to the one record its own
504
+ run already holds a pending request against.
505
+
506
+ **Recovery — a sweep releases records held by runs that died anyway.** A pending
507
+ request whose owning run has reached a terminal state (`completed`, `failed`,
508
+ `cancelled`, `timed_out`) can never be decided, so it is finalised as `recalled`
509
+ — releasing the lock — and audited under the reserved actor `system:dead-run`
510
+ with the run and its status in the comment, so it is never mistaken for a
511
+ submitter's withdrawal. It runs on the existing approvals sweep clock, which also
512
+ covers the case no in-band handler can: a run killed by a process crash.
513
+
514
+ The sweep is fail-safe by construction. It acts only on an explicit terminal
515
+ status from a closed set; `paused` (the normal state of a live approval),
516
+ `running`, an unrecognised status, an unknown run, a `getRun` that throws, and a
517
+ deployment with no automation engine are all read as "still alive". The failure
518
+ mode is "a dead run's lock survives until an admin recalls it" — today's
519
+ behaviour — never "a live approval is destroyed".
520
+
521
+ Also fixes `AutomationEngine.getRun`, which returned the **first** log entry for
522
+ a run id rather than the latest. A run that pauses and later finishes records two
523
+ entries under one id, so every suspend-then-finish run — every approval, screen
524
+ and wait flow — reported itself as `paused` forever, both on the Runs
525
+ observability surface and to this sweep.
526
+
527
+ One shape was left out here and closed separately in #3712: a `runAs:'user'` run
528
+ with no trigger user (a schedule) resolved no ObjectQL context at all, so it
529
+ carried no `flowRunId` and stayed subject to the lock. It now passes a
530
+ provenance-only context — the run id and nothing the security middleware keys on
531
+ — so it is attributable without acquiring a principal, and its documented
532
+ unscoped posture (#1888) is unchanged.
533
+
534
+ - be1c52c: fix(approvals): admin override for a request routed to an unstaffed approver (#3424)
535
+
536
+ An `approval` node routed to a `position` (or `team`/`department`) with **no
537
+ holders** resolved to only the unresolvable `position:<name>` literal in
538
+ `pending_approvers` — no concrete user was in the slate. Every normal
539
+ `decide` / `reassign` / `recall` then returned `FORBIDDEN` (not a pending
540
+ approver) and, with `lockRecord`, the target record stayed `RECORD_LOCKED`
541
+ forever: a data-availability dead-end with no in-product recovery (the only exit
542
+ was editing the DB by hand). Very easy to hit in fresh/demo orgs (positions
543
+ seeded, holders not) and whenever a role is vacated in production.
544
+
545
+ A **platform or tenant admin** — the same posture the engine's superuser bypass
546
+ already trusts — may now act on any _pending_ request to release it: **approve,
547
+ reject, reassign** it to a real approver, or **recall** it. The override finalizes
548
+ the request (which releases the record lock, keyed on a pending request); a
549
+ tenant admin's authority is org-scoped, a platform admin's is not, and the
550
+ decision is audited under the admin's own id. An admin approval is authoritative,
551
+ finalizing the node even under `unanimous` / `quorum` / `per_group` rather than
552
+ counting as one vote among the (empty) slate.
553
+
554
+ - `sys_approval_request.viewer` gains `can_override` (server-computed): true for a
555
+ privileged admin on a pending request. The `approve` / `reject` / `reassign`
556
+ declared actions OR it into their `visible` gate, so the console surfaces the
557
+ recovery path without a hand-wired button. Existing approver/submitter gating is
558
+ unchanged.
559
+ - `openNodeRequest` now logs a loud warning when a node resolves to **no concrete
560
+ approver**, so the misconfiguration is visible instead of silently locking the
561
+ record. The literal-fallback behavior (kept for 15.x slot back-compat) is
562
+ otherwise unchanged.
563
+
564
+ - c5ff96d: fix(approvals): a schedule-triggered run can write its own locked record (#3712)
565
+
566
+ #3456 let the run that opened a pending approval write its own target record,
567
+ keyed on `flowRunId`. It worked for every run that resolves an identity and
568
+ missed the one that doesn't: an effective `runAs:'user'` run with **no trigger
569
+ user** — a schedule being the canonical case — passed no ObjectQL context at
570
+ all, so nothing carried the run id and the run still died on its own
571
+ `RECORD_LOCKED`.
572
+
573
+ The blocker was never the lock. It was that "no identity" and "no context" were
574
+ the same thing on the wire, so a run could not say _who it was_ without also
575
+ claiming _what it was allowed to do_.
576
+
577
+ **A run with no principal now passes provenance alone.**
578
+ `resolveRunDataContext` returns `{ flowRunId }` — no `userId`, no `positions`,
579
+ no `permissions`, not even `isSystem: false`. Every principal gate keys on one
580
+ of those fields (the elevation short-circuit on `isSystem`, the ADR-0103
581
+ engine-owned write guard and the ADR-0090 D12 delegated-admin gate on `userId`,
582
+ the empty-principal fall-open on all three), so this context authorizes
583
+ **identically to no context at all**. The run keeps the documented #1888
584
+ unscoped posture, its loud `[runAs]` warning, and the
585
+ `flow-schedule-runas-unscoped` build-time lint. Nothing about what it may touch
586
+ changed — only that it can now be attributed.
587
+
588
+ **Provenance moved out of the hook session, into `ctx.provenance`.** `session`
589
+ answers _who is calling_ and is absent when no identity envelope was supplied —
590
+ a distinction real gates depend on (the attachment access gate skips bare-kernel
591
+ writes on exactly that test). Folding a run id into `session` would have forced
592
+ an identity-less run to present an empty session, silently turning "no caller"
593
+ into "an anonymous caller" and narrowing the #1888 fail-open for attachments
594
+ alone. `HookContext.provenance.flowRunId` says what produced the write; the
595
+ approvals lock reads it there.
596
+
597
+ Also relaxes `BaseEngineOptionsSchema.context` to a partial envelope
598
+ (`ExecutionContextInput`). `positions`/`permissions`/`isSystem` carry parse-time
599
+ defaults, which made them _required_ on a caller-supplied option and asserted
600
+ something untrue — that every data-engine context carries a principal. Callers
601
+ have always passed slices (`{ isSystem: true }` for a system read); the type now
602
+ says so.
603
+
604
+ Migration: nothing to change unless you read the run id inside a hook. If you
605
+ wrote `ctx.session.flowRunId`, read `ctx.provenance.flowRunId` instead — the
606
+ field never shipped under the old name.
607
+
608
+ - d2a8695: fix(approvals)!: an approval request is visible to its participants, not to the whole tenant (#3590)
609
+
610
+ `getRequest` / `listRequests` / `countRequests` deliberately query with
611
+ `SYSTEM_CTX` to bypass RLS — as the code comments say, the approver-visibility
612
+ rule spans identity forms RLS cannot model cleanly, so it has to be expressed in
613
+ the service. Only the **tenant** half of that rule was ever applied. The
614
+ participant half was named in the comment and never written, so **any
615
+ authenticated user could read any approval request in their tenant** — its
616
+ payload snapshot, its full decision history, and (once decision attachments
617
+ derived their access from the request, #3580) its files.
618
+
619
+ `approverId` on `listRequests` is a _filter_, not authorization: omitting it
620
+ returned the whole tenant.
621
+
622
+ A caller now sees a request when they are a participant — the submitter, a
623
+ current approver (via the normalized approver index, so every identity form the
624
+ write path recorded is covered), or someone who has already acted on it (a past
625
+ approver whose slot has moved on, a commenter). Admins with override authority
626
+ keep the unrestricted view the "all requests" console surface depends on, and a
627
+ tokenless context sees nothing.
628
+
629
+ Keying on the concrete user id is sufficient rather than an approximation:
630
+ position/team/manager/field approvers are resolved to concrete user ids at open
631
+ time, and the `type:value` literal is only the fallback for a spec that resolved
632
+ to _nobody_ — a slot no one can act on either way. So this cannot hide a request
633
+ from someone who could actually act on it.
634
+
635
+ **A write path's own result is not re-gated.** Every operation echoes back the
636
+ request it just changed; the operation already authorized itself, and re-asking
637
+ would answer wrong for a context carrying no `userId` (a flow-driven resume, a
638
+ service-to-service call), turning a successful write into `null`.
639
+
640
+ Marked breaking because a client that listed requests without an `approverId`
641
+ filter and expected the whole tenant will now receive only its own — which is
642
+ the point.
643
+
644
+ - 84e7be9: feat(plugin-approvals): expose per-group membership of pending approvers (objectui#2807)
645
+
646
+ `per_group` (会签) requests now carry `pending_approver_groups` on the
647
+ enriched row — a map from each still-pending approver id to the group key(s)
648
+ it fills (e.g. `{ "u_devadmin": ["finance", "legal"] }`). A client can label
649
+ each "waiting on" chip with the group it represents instead of showing
650
+ duplicate, context-free names.
651
+
652
+ - Resolved in `attachDecisionProgress` from the same open-time
653
+ `__approverGroups` snapshot the `decision_progress` groups already use, so
654
+ the two never disagree.
655
+ - Only the **pending** slots are mapped (a resolved approver has left
656
+ `pending_approvers`), and **synthetic** (unnamed, `#N`) group keys are
657
+ dropped — a `· #0` sub-tag would be noise.
658
+ - Absent for non-`per_group` behaviors. Display-only; the engine's
659
+ finalization tally stays authoritative.
660
+ - Added to the `ApprovalRequestRow` contract in `@objectstack/spec`.
661
+
662
+ - debc23a: feat(approvals): enrich inbox rows with `payload_labels` (snapshot field labels)
663
+
664
+ The approvals inbox summary title-cased raw snapshot machine keys
665
+ (`assessment_status` → "Assessment Status") because the API sent no field
666
+ labels. `ApprovalService.enrichRows` now attaches `payload_labels` (snapshot
667
+ field key → the target object's field label), symmetric with the existing
668
+ `payload_display` (which resolves the values), and `ApprovalRequestRow` gains
669
+ the field. For a single-locale project the schema label is already the
670
+ localized string, so a client can render the human field name (e.g. "考核状态")
671
+ instead of a prettified English key.
672
+
673
+ - 0f8ad09: feat(spec)+fix(approvals): publish approver value data sources, order the type enum for authors, stop silent dead approver slots (#3508 / #3807 follow-ups)
674
+
675
+ Four follow-ups from browser-verifying the #3508 approver work end to end.
676
+
677
+ **`APPROVER_VALUE_SOURCES` — the designer stops guessing where candidates live.**
678
+ `xRef.map` only ever named a picker KIND (`'team'`), never where that picker's
679
+ rows come from, so the designer carried its own copy of the data contract — and
680
+ the first copy was wrong: every directory kind was wired to `GET
681
+ /api/v1/meta/:type`, the metadata REGISTRY, which does not hold `sys_user` /
682
+ `sys_team` / `sys_business_unit` / `sys_position` rows. Candidates came back
683
+ empty and the control degraded to free text (#3508). The binding is now
684
+ projected onto the published JSON schema as `xRef.sources` — `{ source: 'data',
685
+ object, valueField }` for the record-backed kinds, the closed enum inline for
686
+ `org_membership_level` — derived from `APPROVER_VALUE_BINDINGS` so the two
687
+ cannot drift, and inheriting its `satisfies` exhaustiveness (a new
688
+ `ApproverType` member that declares no source is a compile error). Presentation
689
+ — which field to show, whether to open a people-picker, what subtitle to use —
690
+ stays a renderer decision.
691
+
692
+ **`ApproverType` declaration order is now the authoring recommendation.**
693
+ objectui#2834 argued for leading with indirect bindings and shipped that order
694
+ in its own options array — which the Studio inspector never reads: it derives
695
+ the picker from this enum via the published schema, so `user` still came first.
696
+ The intent only takes effect if the enum carries it, so the enum now reads
697
+ `manager, position, department, team, field, expression, org_membership_level,
698
+ user` (deprecated `role` / `queue` still parse and stay out of every picker via
699
+ `xEnumDeprecated`). Binding one specific person is the least portable choice an
700
+ author can make — it breaks when the flow moves to another environment (that id
701
+ does not exist there) and again when that person leaves.
702
+
703
+ **A graph approver that expands to nobody no longer does it in silence.**
704
+ `queue` already warned (#3508); every OTHER graph type — `team`, `department`,
705
+ `position`, `org_membership_level`, `manager` — fell back to the same
706
+ unactionable `type:value` literal without a word. That silence is what let
707
+ #3807 hide for as long as it did: the request opened with an empty slate and
708
+ the first symptom was a permanently stuck approval (#3424). The fallback stays
709
+ (15.x slots and substring fixtures depend on it); it now logs the type, value
710
+ and organization that produced it. `user` / `field` stay quiet — they take the
711
+ id they were given and never had an "expanded to nobody" state.
712
+
713
+ **`plugin-sharing`'s identical org scope is pinned by tests.**
714
+ `BusinessUnitGraphService.orgScope` has the same strict `organization_id`
715
+ equality #3807 fixed in approvals. It is unreachable today — every materialized
716
+ `sys_sharing_rule` carries `organization_id = null`, so the filter is skipped —
717
+ and widening an authorization path on a defect that cannot currently fire is
718
+ not a change to make blind. New tests lock both the reachable paths and the
719
+ divergence itself, so if sharing ever adopts the null-org=env-wide reading it
720
+ is a deliberate edit to a named test rather than a silent behaviour change.
721
+
722
+ - 376a061: Surface the approval node's author-declared `decisionOutputs` keys on the request read as `ApprovalRequestRow.decision_outputs` (#3447 P2 UI enablement). The set varies per request (each node declares its own), so it rides the row rather than the object's static action params — a decision UI renders one input per key and POSTs `outputs` with the decision.
723
+ - 3ea7271: fix(approvals): a `department` approver resolves against env-wide business units (#3807)
724
+
725
+ `expandBusinessUnitUsers` scoped its `sys_business_unit` reads with a strict
726
+ `organization_id = <request org>` equality, so a unit whose `organization_id`
727
+ is `null` was invisible: the seed check found no row, the expansion returned
728
+ `[]`, and the approver fell back to the dead `department:<id>` literal that
729
+ routes to nobody.
730
+
731
+ That is the normal case, not an edge case. An app's org tree is seeded, and a
732
+ seed cannot know the organization id the runtime mints at boot, so every seeded
733
+ unit carries `organization_id = null` — while an approval request always
734
+ carries an org. Every business unit a flow author could pick therefore resolved
735
+ to nobody, silently: the request opens, the slate is empty, and (with
736
+ `lockRecord`) the record stays locked with no one able to act (#3424 is the
737
+ downstream shape of the same dead end). Verified against a live showcase stack:
738
+ a `{ type: 'department', value: 'bu_hq_finance' }` approver produced
739
+ `pending_approvers: "department:bu_hq_finance"` while the unit's member sat
740
+ right there in `sys_business_unit_member`.
741
+
742
+ Both the seed check and the subtree descent now scope to **this org ∪
743
+ env-wide** — `$or: [{ organization_id: <org> }, { organization_id: null }]` —
744
+ the same predicate `sys_metadata`'s pending-draft listing settled on for the
745
+ identical reason (a strict equality silently dropping env-wide rows). The wall
746
+ between two organizations is unchanged: another org's unit still fails the
747
+ match, and a null-org parent does not drag another org's child unit into the
748
+ subtree.
749
+
750
+ Note the same strict-equality scope exists in `plugin-sharing`'s
751
+ `BusinessUnitGraphService.orgScope`. It is not reachable today — every
752
+ materialized `sys_sharing_rule` row carries `organization_id = null`, so the
753
+ filter is skipped — and is left alone here rather than widen an
754
+ access-granting path on a defect that cannot currently fire.
755
+
756
+ - deb538f: fix(storage): let an object delegate file-read authorization to its service
757
+
758
+ Fixes a regression from the governed-download change (ADR-0104 D3 wave 2): a
759
+ **legitimate approver could see a decision attachment's filename but got 403
760
+ opening it**, found by driving app-showcase in a browser as a real non-admin
761
+ approver.
762
+
763
+ Cause: a field-owned file's download was authorized by testing whether the
764
+ caller can READ the owning row. For an ordinary business object that is right —
765
+ row readability _is_ the access rule. For `sys_approval_action` it is the wrong
766
+ authority: the audit table is deliberately closed to ordinary approver
767
+ positions (`operation 'find' … is not permitted for positions [auditor,
768
+ everyone]`), so the test denied the very approver the attachment was filed for.
769
+ The approvals _service_ has always had the real rule, which is why the timeline
770
+ listing the attachment returned 200 while the bytes returned 403.
771
+
772
+ An object may now name a service to answer the question instead:
773
+
774
+ - `ObjectSchema.fileAccessDelegate` — a kernel service that authorizes
775
+ downloads of files owned by that object's media fields.
776
+ - `IFileAccessDelegate.authorizeFileRead(recordId, context)` — the contract.
777
+ - `sys_approval_action` declares `'approvals'`; `ApprovalService.authorizeFileRead`
778
+ reuses the _same_ gate `listActions` applies (visibility of the parent
779
+ request) rather than inventing a second, looser rule for the bytes.
780
+
781
+ **Fails closed**: a declared delegate that is missing or does not implement the
782
+ method denies, rather than silently reverting to the raw read it was declared to
783
+ replace. Objects without the declaration are unchanged.
784
+
785
+ Verified in the browser against app-showcase, both sides of the gate: the
786
+ approver now downloads the real PDF (200), and an anonymous request is still
787
+ refused (401) — the anonymous capability URL the original change closed stays
788
+ closed. A decision attachment ends up exactly as readable as the decision it
789
+ hangs off: never more, and no longer less.
790
+
791
+ - db48ad5: fix(security,approvals,metadata-core): restore batch routes on the eight objects the #3391 P1 companion fix missed (#3026)
792
+
793
+ The #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`: a batch
794
+ request is admitted only when the object grants the `bulk` **primitive** and the
795
+ batched child operation is itself allowed. Before that, the `*Many` routes
796
+ checked only the child verb, so a boilerplate CRUD-five whitelist
797
+ (`['get','list','create','update','delete']`) batched fine.
798
+
799
+ The companion fix — adding the `bulk` primitive wherever an explicit whitelist
800
+ survived — was applied only inside `platform-objects`. Eight objects carrying
801
+ the same boilerplate live in other packages and kept the gap, so `/batch`,
802
+ `createMany`, `updateMany` and `deleteMany` answered `405
803
+ OBJECT_API_METHOD_NOT_ALLOWED` on objects whose single-record create/update/
804
+ delete were wide open. `data-objectstack` rethrows that 405 without falling back
805
+ to per-row writes, which surfaced as a hard error on multi-select delete in the
806
+ Setup grids.
807
+
808
+ Objects reclaimed (whitelist now `['get','list','create','update','delete','bulk']`):
809
+ `sys_capability`, `sys_permission_set`, `sys_position`,
810
+ `sys_position_permission_set`, `sys_user_permission_set`, `sys_user_position`
811
+ (plugin-security); `sys_approval_delegation` (plugin-approvals);
812
+ `sys_view_definition` (metadata-core).
813
+
814
+ No new authority is granted: `bulk` only permits batching verbs each object
815
+ already exposes one record at a time, and every batched row still passes the
816
+ same row- and field-level permission checks. The whitelists stay explicit rather
817
+ than being deleted — seven of the eight are `managedBy`, and
818
+ `reconcileManagedApiMethods` (ADR-0103 D3) early-returns on a non-array
819
+ `apiMethods`, so dropping the line would silently disable the managed-write
820
+ backstop.
821
+
822
+ - 83c161f: feat(automation)!: a flow run with no trigger user may no longer touch data (#3760)
823
+
824
+ An effective `runAs:'user'` run that resolves **no trigger user** used to execute
825
+ its data nodes **UNSCOPED** — it presented no principal, and the data security
826
+ middleware skips when there is no principal, so the run read and wrote every row.
827
+ `runAs:'user'` is an access-_narrowing_ declaration; failing to resolve it must
828
+ never resolve to a grant (ADR-0049). It now **refuses** the operation
829
+ (`UnscopedRunDataAccessError`), naming `runAs:'system'` as the fix.
830
+
831
+ **This was never really about schedules.** The docs, the spec, the runtime
832
+ warning and the lint all described a schedule-shaped problem, and the lint only
833
+ ever matched that shape. But the runtime predicate is "no user", and the
834
+ commonest way to have no user is a **record-change flow fired by a write that
835
+ carried none**: `isSystem` does _not_ suppress trigger dispatch — only
836
+ `skipTriggers` does, and exactly three first-party paths set it — so every
837
+ plugin/service system write, the approvals status mirror, and a `runAs:'system'`
838
+ flow's own data node dispatched record-change flows with `userId: undefined`.
839
+ Ordinary users reach those writes routinely (submitting for approval mirrors a
840
+ status onto the target record), so the fail-open was reachable by unprivileged
841
+ input and was the common case, not the rare one.
842
+
843
+ Deliberately **not** implemented as "inherit the triggering write's posture and
844
+ run as `isSystem`". That reads like a relabel but is a privilege escalation: the
845
+ security middleware's `isSystem` short-circuit fires _before_ its
846
+ package-managed-row, system-row, audience-anchor and delegated-admin gates, all
847
+ of which a principal-less context still has to clear. Such a run cannot write
848
+ `sys_user_position` today; as `isSystem` it could. "Unscoped" was never
849
+ equivalent to "system".
850
+
851
+ **Breaking — how to migrate.** A flow that reacts to system writes and needs to
852
+ act beyond one user's grants declares `runAs: 'system'`, making the elevation
853
+ explicit and audit-attributable. Otherwise ensure the trigger supplies a user.
854
+ Flows that touch no data are unaffected (`runAs` is moot), and the failure is
855
+ isolated: the trigger already swallows flow errors, so the originating write
856
+ still succeeds. The engine warns at run _setup_, before any node executes.
857
+
858
+ **#3712's user-less provenance path is subsumed, not broken.** That fix let a
859
+ run with no trigger user write its own approval-locked record by carrying a
860
+ provenance-only ObjectQL context (the run id, nothing else). Such a run can no
861
+ longer perform a data operation at all — presenting no principal is exactly what
862
+ made the write unscoped — so it is refused before the lock is consulted. The
863
+ capability survives via the explicit route: a schedule that must write records
864
+ declares `runAs:'system'`, which the lock hook exempts on its own `isSystem`
865
+ branch. The `flowRunId` exemption itself stays live and load-bearing for what
866
+ #3703 built it for — a `runAs:'user'` run that _does_ have a user — where the
867
+ exemption is still provenance rather than privilege.
868
+
869
+ Also in this change:
870
+
871
+ - **`flow-schedule-runas-unscoped` → `flow-runas-unscoped`, and it now fails the
872
+ build.** It read as a gate and behaved as a comment — `os compile` documented
873
+ that the flow lint "NEVER fails the build" — which is close to no net at all
874
+ for the audience it protects, very often an AI generating flows in bulk. It now
875
+ also covers the other provably user-less triggers (`time_relative`, `api`), per
876
+ ADR-0073 D5. It still cannot cover `record_change`, which is undecidable at
877
+ authoring time — that is exactly why the runtime refusal exists.
878
+ - **Three seed writes stopped firing automation.** The seed loader's pass-2
879
+ deferred-reference back-fill and both of `AppPlugin`'s basic-insert fallbacks
880
+ inlined a bare `{ isSystem: true }` instead of the shared seed options, so they
881
+ seeded with record-change automation live — the self-trigger vector
882
+ `skipTriggers` exists to prevent, on the writes that skipped it.
883
+ - **ADR-0073 amended.** Its severity rationale ("an unprivileged user cannot
884
+ trigger a schedule, so there is no untrusted-input path") is falsified, and its
885
+ rejection of fail-closed ("breaks legitimate scheduled CRUD — 2/3 example flows
886
+ relied on the default") expired when those flows were fixed to declare
887
+ `runAs:'system'`. Refusal is an interim posture, forward-compatible with the
888
+ ADR's `automation` principal: when that lands, the refusal point becomes the
889
+ place that resolves it.
890
+
891
+ - Updated dependencies [50616d9]
892
+ - Updated dependencies [08b5a3d]
893
+ - Updated dependencies [d99aeb3]
894
+ - Updated dependencies [4727eb8]
895
+ - Updated dependencies [f63cd09]
896
+ - Updated dependencies [fa3d0cf]
897
+ - Updated dependencies [af5a224]
898
+ - Updated dependencies [71f76e1]
899
+ - Updated dependencies [37b1346]
900
+ - Updated dependencies [99736a0]
901
+ - Updated dependencies [fe67e34]
902
+ - Updated dependencies [fdb4f50]
903
+ - Updated dependencies [1bd5652]
904
+ - Updated dependencies [14252d3]
905
+ - Updated dependencies [7fb436c]
906
+ - Updated dependencies [879ea13]
907
+ - Updated dependencies [201b31f]
908
+ - Updated dependencies [e2616e0]
909
+ - Updated dependencies [6fdc5c6]
910
+ - Updated dependencies [8b9d71e]
911
+ - Updated dependencies [33f5e23]
912
+ - Updated dependencies [259af21]
913
+ - Updated dependencies [587fc91]
914
+ - Updated dependencies [1986594]
915
+ - Updated dependencies [ad4af62]
916
+ - Updated dependencies [d44dbfa]
917
+ - Updated dependencies [474fe39]
918
+ - Updated dependencies [0bc685a]
919
+ - Updated dependencies [b949059]
920
+ - Updated dependencies [be1c52c]
921
+ - Updated dependencies [c5ff96d]
922
+ - Updated dependencies [84e7be9]
923
+ - Updated dependencies [a6c3f38]
924
+ - Updated dependencies [debc23a]
925
+ - Updated dependencies [0f8ad09]
926
+ - Updated dependencies [8f9689f]
927
+ - Updated dependencies [57a3bb3]
928
+ - Updated dependencies [5f9a987]
929
+ - Updated dependencies [9f060e5]
930
+ - Updated dependencies [bc17d39]
931
+ - Updated dependencies [db02d47]
932
+ - Updated dependencies [0bfdf46]
933
+ - Updated dependencies [376a061]
934
+ - Updated dependencies [7c7e246]
935
+ - Updated dependencies [f35cdc5]
936
+ - Updated dependencies [9ea2bc5]
937
+ - Updated dependencies [c2d9098]
938
+ - Updated dependencies [a227ed7]
939
+ - Updated dependencies [9613396]
940
+ - Updated dependencies [e47b342]
941
+ - Updated dependencies [4ed7ed4]
942
+ - Updated dependencies [2fa4ca1]
943
+ - Updated dependencies [f5a2320]
944
+ - Updated dependencies [deb538f]
945
+ - Updated dependencies [5b89711]
946
+ - Updated dependencies [0c8a22f]
947
+ - Updated dependencies [763931e]
948
+ - Updated dependencies [de9af8a]
949
+ - Updated dependencies [c4df271]
950
+ - Updated dependencies [a41ba5c]
951
+ - Updated dependencies [189854c]
952
+ - Updated dependencies [0e3a226]
953
+ - Updated dependencies [524151c]
954
+ - Updated dependencies [1d4756e]
955
+ - Updated dependencies [720c5ad]
956
+ - Updated dependencies [a8d1e24]
957
+ - Updated dependencies [d1cabaa]
958
+ - Updated dependencies [41642b0]
959
+ - Updated dependencies [4cca74c]
960
+ - Updated dependencies [88ef03e]
961
+ - Updated dependencies [9e2caf3]
962
+ - Updated dependencies [81ce41a]
963
+ - Updated dependencies [85e1e4e]
964
+ - Updated dependencies [dac6a08]
965
+ - Updated dependencies [394b7a1]
966
+ - Updated dependencies [677b591]
967
+ - Updated dependencies [d77d1b7]
968
+ - Updated dependencies [5b79a34]
969
+ - Updated dependencies [c757854]
970
+ - Updated dependencies [0045682]
971
+ - Updated dependencies [2a5f04a]
972
+ - Updated dependencies [4f740b0]
973
+ - Updated dependencies [67452d1]
974
+ - Updated dependencies [4921a95]
975
+ - Updated dependencies [0fc6219]
976
+ - Updated dependencies [605e190]
977
+ - Updated dependencies [c6c59f1]
978
+ - Updated dependencies [b0e78a8]
979
+ - Updated dependencies [f31cc8d]
980
+ - Updated dependencies [f343dc4]
981
+ - Updated dependencies [8269e32]
982
+ - Updated dependencies [74f7339]
983
+ - Updated dependencies [a6c35a2]
984
+ - Updated dependencies [c2f1002]
985
+ - Updated dependencies [db48ad5]
986
+ - Updated dependencies [f163028]
987
+ - Updated dependencies [f07808c]
988
+ - Updated dependencies [7ffc3d3]
989
+ - Updated dependencies [88346ba]
990
+ - Updated dependencies [4631592]
991
+ - Updated dependencies [32ff033]
992
+ - Updated dependencies [5ac93d4]
993
+ - Updated dependencies [93f267f]
994
+ - Updated dependencies [0024abf]
995
+ - Updated dependencies [acbf364]
996
+ - Updated dependencies [5487c20]
997
+ - Updated dependencies [aa8b847]
998
+ - Updated dependencies [7687f7b]
999
+ - Updated dependencies [1659072]
1000
+ - Updated dependencies [abceb0d]
1001
+ - Updated dependencies [0c302a7]
1002
+ - Updated dependencies [6633337]
1003
+ - Updated dependencies [f00d8d4]
1004
+ - Updated dependencies [503be86]
1005
+ - Updated dependencies [cde1975]
1006
+ - Updated dependencies [0bc685a]
1007
+ - Updated dependencies [c073b8c]
1008
+ - Updated dependencies [11949fc]
1009
+ - Updated dependencies [b098b0e]
1010
+ - Updated dependencies [4d00b13]
1011
+ - Updated dependencies [9aa5510]
1012
+ - Updated dependencies [57bab76]
1013
+ - Updated dependencies [b90086a]
1014
+ - Updated dependencies [b95577a]
1015
+ - Updated dependencies [83c161f]
1016
+ - Updated dependencies [d8c4957]
1017
+ - Updated dependencies [f24cb83]
1018
+ - Updated dependencies [5dbbb92]
1019
+ - Updated dependencies [69f1dfd]
1020
+ - @objectstack/spec@17.0.0-rc.0
1021
+ - @objectstack/platform-objects@17.0.0-rc.0
1022
+ - @objectstack/core@17.0.0-rc.0
1023
+ - @objectstack/formula@17.0.0-rc.0
1024
+ - @objectstack/metadata-core@17.0.0-rc.0
1025
+
1026
+ ## 16.1.0
1027
+
1028
+ ### Patch Changes
1029
+
1030
+ - Updated dependencies [212b66a]
1031
+ - Updated dependencies [d10c4dc]
1032
+ - Updated dependencies [9e45b63]
1033
+ - Updated dependencies [b20201f]
1034
+ - @objectstack/platform-objects@16.1.0
1035
+ - @objectstack/spec@16.1.0
1036
+ - @objectstack/core@16.1.0
1037
+ - @objectstack/formula@16.1.0
1038
+ - @objectstack/metadata-core@16.1.0
1039
+
3
1040
  ## 16.0.0
4
1041
 
5
1042
  ### Minor Changes