@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1467 @@
1
1
  # @objectstack/plugin-approvals
2
2
 
3
+ ## 17.0.0-rc.1
4
+
5
+ ### Minor Changes
6
+
7
+ - f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003)
8
+
9
+ Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed
10
+ the set; this batch moves the emitters that still spoke lowercase `snake_case`
11
+ onto it.
12
+
13
+ **Wire-visible change.** Error codes on these surfaces change spelling. Generic
14
+ conditions collapse onto the standard catalog rather than keeping a synonym:
15
+ `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` →
16
+ `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` →
17
+ `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` →
18
+ `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes
19
+ registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`,
20
+ `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept:
21
+ `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`,
22
+ `service-messaging`, `service-automation`, `trigger-api`.
23
+
24
+ Branch on `error.code` values rather than pattern-matching their case: the
25
+ console's fix for the same rename (objectui#2977) reads codes case-insensitively
26
+ for exactly this reason, and that is the pattern to copy in your own consumers if
27
+ you support servers on both sides of the change.
28
+
29
+ **Four routes stop putting a code in the message slot.** The webhook redeliver
30
+ route, the API-trigger webhook, and two `rest` routes answered
31
+ `{ success: false, error: '<code>', message }` — the code occupying `error`, the
32
+ declared object envelope nowhere. They now emit `error: { code, message }`, and
33
+ three API-trigger branches gained a message they never had. Clients reading
34
+ `body.error` as a string on those routes must read `body.error.code`.
35
+
36
+ **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a):
37
+ `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and
38
+ two `RetryStrategy` types. The connector-side pair is renamed; importers of the
39
+ `integration` subpath update the name. Side effect: the api-side `ErrorCategory`
40
+ and `RetryStrategy` now appear in the generated API reference at all — the name
41
+ collision had been silently dropping them.
42
+
43
+ **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client
44
+ registration route put better-auth's arbitrary `body.error` string straight into
45
+ `error.code`. The code is now ours and the upstream discriminator moved to
46
+ `details.upstreamError`.
47
+
48
+ **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values
49
+ (ADR-0112 D6b): it is persisted audit history, and the same column holds
50
+ non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a
51
+ 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's
52
+ `--json` output contract.
53
+
54
+ A `check:error-code-casing` CI guard now fails on a new lowercase literal in a
55
+ code position, since the ledger's casing rule can only police codes that someone
56
+ registers.
57
+
58
+ - 91f4c78: feat(approvals,spec): structured reassign hand-off parties on `sys_approval_action` (#4365)
59
+
60
+ A reassign's audit row used to encode "who handed the slot to whom" only inside
61
+ a default free-text comment — `"<from_id> → <to_id>"`, two raw user ids — which
62
+ clients could neither parse reliably nor render readably, so the approvals
63
+ timeline showed opaque identifier soup for the single most important fact of
64
+ the entry.
65
+
66
+ - `sys_approval_action` gains `reassign_from` / `reassign_to`
67
+ (`lookup('sys_user')`), written by `ApprovalService.reassign()`.
68
+ - `comment` is pure user input again: nothing is invented when the actor
69
+ supplies none.
70
+ - `listActions()` resolves both parties' display names into
71
+ `reassign_from_name` / `reassign_to_name`, alongside the existing
72
+ `actor_name`, so timelines can render "from A to B" without extra lookups.
73
+ - `ApprovalActionRow` (spec contract) declares the four new fields.
74
+
75
+ Pre-existing rows keep their legacy comment; clients should prefer the
76
+ structured fields when present and fall back to `comment` otherwise.
77
+
78
+ - cd6b9f2: `decisionOutputs` entries may now be declared `required` (objectui#2955). A typed entry `{ key, label?, type?, multiple?, required?: true }` tells the runtime — not just the decision UI — that an approver must supply the value: an **approve** carrying no value, or a blank one (`''`, whitespace, `[]`, an array of blanks), is rejected with `VALIDATION_FAILED` before any write, so the audit row and the request are untouched and the run can never resume past the node with the key missing.
79
+
80
+ That gap is what the flag closes. `decisionOutputs` exists so a decision can route the next step (`approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }]`), but nothing made the approver actually answer: a skipped output resumed the run with the key absent, and the next node either faulted with `EXPRESSION_FAILED` or resolved an empty slate and stalled on `onEmptyApprovers: 'admin_rescue'` — long after the one person who could have filled it in had moved on. `onEmptyApprovers` was the only backstop, and it is a recovery mechanism, not a contract.
81
+
82
+ **Reject never requires them.** The run leaves down the `reject` edge, where nothing reads the outputs — demanding routing data to say "no" would trap the rejection. Outputs still ride a reject when the approver filled them in.
83
+
84
+ **No elevation bypass.** A one-click email action link and an `auto_approve` SLA escalation both fail the same way rather than advancing into a node that would resolve nobody; the escalation sweep already isolates a throwing request, so that decision stays pending and visibly overdue instead of silently breaking the run downstream. Enforcement is per decision, so on a `unanimous` / `quorum` node every approver supplies the required outputs and the finalizing decision's values are what the flow resumes with.
85
+
86
+ `required` rides `normalizeDecisionOutputs`, so it reaches clients on `decision_output_defs` — a decision UI marks the field required and blocks locally instead of round-tripping to a 400. The console side ships in objectui#2955.
87
+
88
+ ### Patch Changes
89
+
90
+ - 820eff9: fix(spec,plugin-approvals): the two approval vocabularies are derived, not hand-matched (#3786)
91
+
92
+ `sys_approval_request.status` and `sys_approval_action.action` spelled their
93
+ option lists out — five values and twelve — each under a "Keep in sync with
94
+ `ApprovalStatus` / `ApprovalActionKind` (spec/contracts)" comment, while the
95
+ contract held the same sets as bare type unions. Seventeen strings matched by
96
+ hand across a package boundary, with nothing checking them. They did all still
97
+ agree; the sweep that found them (#3786) verified that verbatim before changing
98
+ anything.
99
+
100
+ Agreeing is not the same as being held, and both directions of drift are quiet:
101
+
102
+ - a value the **column** accepts and the contract omits is invisible to every
103
+ consumer typed against the contract — the row exists and nothing can narrow it;
104
+ - a value the **contract** declares and the column rejects surfaces only at write
105
+ time, on whichever tenant first reaches that transition.
106
+
107
+ An audit vocabulary is a bad place for either. So the contract now publishes the
108
+ lists as values — `APPROVAL_STATUSES` and `APPROVAL_ACTION_KINDS` — with
109
+ `ApprovalStatus` / `ApprovalActionKind` derived from them via
110
+ `(typeof X)[number]`, and the two columns spread the constants. The per-entry
111
+ rationale (which action kinds move the flow, which are thread-only, why
112
+ `returned` differs from `recalled`) moved onto the constants, where the values
113
+ live.
114
+
115
+ **New exports, no behaviour change.** The emitted option lists are byte-identical
116
+ — verified against the built artifact before and after. Existing imports of the
117
+ two types are unaffected; the types resolve to the same unions.
118
+
119
+ `approval-vocabularies.test.ts` pins the qualifier that derivation alone cannot:
120
+ the columns agree with the contract _while the spread is there_, and the test
121
+ fails if either is re-inlined as a literal that has drifted. It also guards the
122
+ guard (an unresolvable import would compare two empty lists and pass) and asserts
123
+ the two vocabularies stay distinct, since a copy-paste pointing one column at the
124
+ other constant would satisfy "derived from the contract" while being the wrong
125
+ vocabulary entirely.
126
+
127
+ Verified by mutation in both directions: adding a value to `APPROVAL_STATUSES`
128
+ propagates into the built `sys_approval_request.status` options (the derivation
129
+ is live, not a stale build), and re-inlining a drifted literal fails
130
+ `sys_approval_request.status offers exactly the contract statuses, in order`.
131
+
132
+ - 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261)
133
+
134
+ The AGENTS.md post-task checklist requires breaking changesets to carry their
135
+ FROM → TO migration because "this text ships to consumers as `CHANGELOG.md`
136
+ inside the npm package and is what an upgrading agent greps after the tombstone
137
+ error." That delivery path was severed for 68 of the 69 publishable packages:
138
+ npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike
139
+ older npm versions — not `CHANGELOG.md`, and the canonical
140
+ `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm
141
+ 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its
142
+ 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it
143
+ explicitly.
144
+
145
+ The tombstone-error scenario is precisely the one where the repo is out of
146
+ reach — the upgrading agent has `node_modules` and nothing else — so the
147
+ migration text has to ride in the tarball. Every publishable package now
148
+ declares `CHANGELOG.md` in `files`, and the canonical whitelist is
149
+ `["dist", "README.md", "CHANGELOG.md"]`.
150
+
151
+ The other half is the gate: `check:published-files` gains a fifth invariant,
152
+ COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the
153
+ always-required lint job, so the next package cannot silently sever the path
154
+ again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves
155
+ into the canonical set.
156
+
157
+ Consumer-visible change: one more file per install (the package's changelog,
158
+ e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key"
159
+ node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was
160
+ promised.
161
+
162
+ - b5f9397: fix(sharing,runtime): a `sort` passed straight to the engine never ordered anything; migrate every in-repo engine call to canonical QueryAST keys (#4346)
163
+
164
+ Two changes with different weights, from one sweep of every in-repo engine
165
+ call site that still speaks a deprecated alias.
166
+
167
+ **The bug — three dropped sorts.** #4346 made the engine fold `filter`→`where`
168
+ and `top`→`limit` on all six methods. The other four pairs in
169
+ `RPC_QUERY_ALIAS_SLOTS` (`select`, `sort`, `skip`, `populate`) are folded at
170
+ the RPC/wire layer only — their values need shape lowering that belongs to
171
+ those layers — and a **direct `engine.find()` never crosses that layer**. Three
172
+ call sites passed `sort` there, so it rode onto the AST untouched, every
173
+ driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and
174
+ the query returned an ordinary-looking, arbitrarily-ordered result:
175
+
176
+ | call site | asked for | actually got |
177
+ | ----------------------------------- | ------------------------------------------------- | --------------------------- |
178
+ | `share-link-routes.ts` | shared AI conversation messages, `created_at asc` | messages in arbitrary order |
179
+ | `runtime/domains/share-links.ts` | same route, runtime-domain copy | same |
180
+ | `share-link-service.ts` `listLinks` | the 200 most recent share links | an arbitrary 200 |
181
+
182
+ All three combine the dropped sort with a `limit` — the "latest N" shape whose
183
+ failure #4226 spelled out: an unapplied sort returns rows in arbitrary order,
184
+ which `limit` then slices into an arbitrary page. #4226 fixed that in the wire
185
+ normalizer; these calls sit one layer below it. `listLinks` had no test at all,
186
+ which is why it went unnoticed. Now pinned — on the option bag the engine
187
+ receives, not on row order, because the failure is that the key never becomes
188
+ `orderBy` and a fake engine honouring either spelling would pass either way.
189
+
190
+ **The cleanup — 27 no-op renames.** Every remaining in-repo engine call passing
191
+ `filter` now passes `where` (approvals 5, auth 2, reports 6, sharing 11,
192
+ webhooks 2, plus the one `filters` in a spec doc example). These are strict
193
+ no-ops since #4346 folds the alias — the point is that the framework stops
194
+ depending on a spelling it asks users to migrate off, which is a prerequisite
195
+ for ever retiring the aliases. Service-level `filter` PARAMETERS (each
196
+ service's own public API, e.g. `listRequests(filter)`) are deliberately
197
+ untouched — those are not engine option bags.
198
+
199
+ Two of the renamed calls were live victims of the #4346 bug rather than
200
+ cosmetic: `auth-manager`'s `stampIdentitySource` read the table's first row via
201
+ `findOne({filter})` and counted the whole table via `count({filter})`, so a
202
+ federated sign-in never stamped `source: 'idp_provisioned'`. #4346 already
203
+ corrected the behaviour; this makes the call say what it means.
204
+
205
+ - 9881074: fix(batch): the background walks seek instead of counting, so they stop skipping rows (#4363)
206
+
207
+ #4363 made a single paged read a partition of its result set. It could not make
208
+ a _walk_ one: seven background scans paged with a growing `offset` while writing
209
+ to the very rows they were reading, and an offset counts into a set those writes
210
+ are changing. Rows slide past the cursor and are never visited.
211
+
212
+ That is not a slow page in any of these — it is a wrong answer wearing the shape
213
+ of a clean run:
214
+
215
+ - **`rebuildApproverIndex`** built its desired state by walking
216
+ `sys_approval_request WHERE status = 'pending'` with no `orderBy` at all, then
217
+ **deleted** every index row that state did not explain. A skipped request
218
+ meant an approver silently dropped from someone's queue. (The loop beside it
219
+ ordered by `created_at` — not unique, so its pages were never a partition
220
+ either.)
221
+ - **`verifyFileReferences`** decides which files nothing references. A record it
222
+ never visits is reported as an unreferenced file.
223
+ - **`backfillFileReferences`** and the **pinyin companion backfill** rewrite
224
+ each row they read, so their own writes were shifting the set out from under
225
+ the cursor. Records were left unconverted and unsearchable by a run that
226
+ reported success.
227
+ - **`scanValueShapes`** exists to vouch that no stored value is off-shape, and
228
+ it opens a migration gate on that evidence.
229
+
230
+ All of them now go through `keysetWalk` (`@objectstack/types`): order by a
231
+ unique key, and seek past the last one instead of counting from the start. A
232
+ row's key does not move when the row is updated, and cannot be shifted when
233
+ another is deleted, so the walk is stable under exactly the mutation these
234
+ functions perform. It is also O(n) rather than O(n²/page) — measured on
235
+ Postgres over 2M rows, deep pages cost ~1.1 s by offset against ~0.09 s by seek.
236
+
237
+ One deliberate non-conversion: the REST **export** stream keeps its offset. It
238
+ honors a caller-chosen sort, and a keyset walk would have to re-order the export
239
+ by `id` to seek — changing what the user asked for to fix a cost. Its pages are
240
+ already a partition since #4363; only the depth cost remains.
241
+
242
+ `keysetWalk` merges the cursor with `$and` rather than spreading it into the
243
+ caller's filter, so a walk whose own `where` constrains the key column
244
+ (`{ id: { $in: [...] } }`) keeps that constraint instead of having it silently
245
+ overwritten. When a `max` cap is set it reads one row beyond the cap to tell
246
+ "the cap stopped us" from "the source ended exactly there" — without that, a
247
+ walk that read everything still reports `truncated`, and a caller acting on it
248
+ goes looking for rows that were never withheld.
249
+
250
+ The storage suites' fake engines now **throw** on an `offset` instead of serving
251
+ one, so the conversion is pinned rather than merely passing.
252
+
253
+ - cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248)
254
+
255
+ These 20 packages declared no `files` field, so npm fell back to packing the
256
+ whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks`
257
+ listed **21 files** — 15 under `src/`, three of them unit tests
258
+ (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the
259
+ build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at
260
+ publish time rather than instead of it, so consumers were installing the
261
+ TypeScript sources and the test suite alongside the artifact they asked for.
262
+
263
+ Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages
264
+ that already did. Nothing a consumer imports moves: every `main` / `types` /
265
+ `exports` target in all 20 already resolved inside `dist/`, which the new
266
+ `check:published-files` guard verifies rather than assumes. The visible change
267
+ is a smaller install and a smaller dependency-scanning surface — `npm pack` on
268
+ `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`.
269
+
270
+ The other half of the fix is the gate. Half the packages declaring `files` and
271
+ half not was the #3786 shape — a hand-copied convention with nothing enforcing
272
+ it, where whoever forgets the line gets no signal at all. `check:published-files`
273
+ (new, wired into the always-required `lint` job) holds every non-private
274
+ workspace package to four invariants: `files` is **declared**; it is
275
+ **sufficient** (covers every entry point, so tightening a whitelist cannot ship
276
+ a package that fails to resolve); it is **minimal** (admits no test, test-harness
277
+ config or build script); and anything beyond `dist` + `README.md` is
278
+ **registered** with a reason, reconciled in both directions so a stale exemption
279
+ is an error rather than dead text. `@objectstack/spec` is the one package with
280
+ registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and
281
+ `CHANGELOG.md` are product, not build input.
282
+
283
+ This also closes an assumption #4206 was resting on. Excluding `<pkg>/scripts/**`
284
+ from the docs-drift implementation test is sound only while no package publishes
285
+ `scripts/` as runtime code; that held, but it held because someone read all three
286
+ offenders by hand. It is now checked on every PR.
287
+
288
+ - Updated dependencies [6a67d7a]
289
+ - Updated dependencies [0ecc656]
290
+ - Updated dependencies [06772eb]
291
+ - Updated dependencies [270650f]
292
+ - Updated dependencies [3aef718]
293
+ - Updated dependencies [1ea6bce]
294
+ - Updated dependencies [c1dcacd]
295
+ - Updated dependencies [ad303ed]
296
+ - Updated dependencies [32ccb23]
297
+ - Updated dependencies [f5a4ef0]
298
+ - Updated dependencies [2d3e255]
299
+ - Updated dependencies [7d7521f]
300
+ - Updated dependencies [5dc4d02]
301
+ - Updated dependencies [05154a1]
302
+ - Updated dependencies [9b6fe7c]
303
+ - Updated dependencies [8c711fb]
304
+ - Updated dependencies [09e4547]
305
+ - Updated dependencies [91f4c78]
306
+ - Updated dependencies [820eff9]
307
+ - Updated dependencies [8d895ff]
308
+ - Updated dependencies [f6472d7]
309
+ - Updated dependencies [78caf51]
310
+ - Updated dependencies [62a789b]
311
+ - Updated dependencies [789ad63]
312
+ - Updated dependencies [2af1988]
313
+ - Updated dependencies [0af50a3]
314
+ - Updated dependencies [2e836de]
315
+ - Updated dependencies [12a19a8]
316
+ - Updated dependencies [41dcda3]
317
+ - Updated dependencies [c8124e5]
318
+ - Updated dependencies [a1a4140]
319
+ - Updated dependencies [c20b875]
320
+ - Updated dependencies [2a37694]
321
+ - Updated dependencies [217e2e6]
322
+ - Updated dependencies [86a71d1]
323
+ - Updated dependencies [d5c75e2]
324
+ - Updated dependencies [03d26f7]
325
+ - Updated dependencies [4384921]
326
+ - Updated dependencies [3c628ce]
327
+ - Updated dependencies [7cb922e]
328
+ - Updated dependencies [1d22114]
329
+ - Updated dependencies [b5f9397]
330
+ - Updated dependencies [ed77493]
331
+ - Updated dependencies [58a03d2]
332
+ - Updated dependencies [dc530b4]
333
+ - Updated dependencies [e59786e]
334
+ - Updated dependencies [bcf1112]
335
+ - Updated dependencies [9774b78]
336
+ - Updated dependencies [b07d829]
337
+ - Updated dependencies [a648e96]
338
+ - Updated dependencies [a47ac06]
339
+ - Updated dependencies [e4c61a7]
340
+ - Updated dependencies [cc60165]
341
+ - Updated dependencies [081aa6f]
342
+ - Updated dependencies [91f4c78]
343
+ - Updated dependencies [e8d0c21]
344
+ - Updated dependencies [45dc446]
345
+ - Updated dependencies [c1d44f7]
346
+ - Updated dependencies [ab9fb5c]
347
+ - Updated dependencies [f985b3f]
348
+ - Updated dependencies [9a4932a]
349
+ - Updated dependencies [f9fc874]
350
+ - Updated dependencies [011b386]
351
+ - Updated dependencies [9881074]
352
+ - Updated dependencies [7777e8f]
353
+ - Updated dependencies [507b92a]
354
+ - Updated dependencies [7309c81]
355
+ - Updated dependencies [20bc1ec]
356
+ - Updated dependencies [90c2b15]
357
+ - Updated dependencies [39eb01b]
358
+ - Updated dependencies [42eeb7d]
359
+ - Updated dependencies [01e124d]
360
+ - Updated dependencies [7ce02eb]
361
+ - Updated dependencies [a13827e]
362
+ - Updated dependencies [7733604]
363
+ - Updated dependencies [40e420f]
364
+ - Updated dependencies [d13004a]
365
+ - Updated dependencies [be7360c]
366
+ - Updated dependencies [cc2de0e]
367
+ - Updated dependencies [5b47ab5]
368
+ - Updated dependencies [b09d8d9]
369
+ - Updated dependencies [b09d8d9]
370
+ - Updated dependencies [8675db6]
371
+ - Updated dependencies [b09d8d9]
372
+ - Updated dependencies [3eb1b2b]
373
+ - Updated dependencies [59b85c0]
374
+ - Updated dependencies [6e357ed]
375
+ - Updated dependencies [d6938bf]
376
+ - Updated dependencies [31e0be9]
377
+ - Updated dependencies [4bfd455]
378
+ - Updated dependencies [ffd2ce2]
379
+ - Updated dependencies [62f8017]
380
+ - Updated dependencies [a831df1]
381
+ - Updated dependencies [f752ee3]
382
+ - Updated dependencies [a1b61e0]
383
+ - Updated dependencies [cd6b9f2]
384
+ - Updated dependencies [2cb6d3c]
385
+ - Updated dependencies [af2a095]
386
+ - Updated dependencies [ec796d5]
387
+ - Updated dependencies [e87fea1]
388
+ - Updated dependencies [c65e529]
389
+ - Updated dependencies [3ca34c1]
390
+ - Updated dependencies [239c3a3]
391
+ - Updated dependencies [94a0bbc]
392
+ - Updated dependencies [d6bfb3d]
393
+ - Updated dependencies [a2266a6]
394
+ - Updated dependencies [d25a0ec]
395
+ - Updated dependencies [667b83e]
396
+ - Updated dependencies [627b188]
397
+ - Updated dependencies [8d4eae7]
398
+ - Updated dependencies [857a6cf]
399
+ - Updated dependencies [65a3a84]
400
+ - Updated dependencies [d5749d7]
401
+ - Updated dependencies [ccd9397]
402
+ - Updated dependencies [bca935b]
403
+ - Updated dependencies [d92c72d]
404
+ - Updated dependencies [c54c822]
405
+ - Updated dependencies [8dcc0f5]
406
+ - Updated dependencies [75b9e51]
407
+ - Updated dependencies [0a2f233]
408
+ - Updated dependencies [8621cdd]
409
+ - Updated dependencies [6f23667]
410
+ - Updated dependencies [5d21a48]
411
+ - Updated dependencies [19365b7]
412
+ - Updated dependencies [b7ed26d]
413
+ - Updated dependencies [68dea0b]
414
+ - Updated dependencies [64f8cbe]
415
+ - Updated dependencies [b3a3d83]
416
+ - Updated dependencies [7a55913]
417
+ - Updated dependencies [35accbf]
418
+ - Updated dependencies [6038de7]
419
+ - Updated dependencies [eb95d97]
420
+ - Updated dependencies [e4c2dc8]
421
+ - Updated dependencies [1bd2795]
422
+ - Updated dependencies [8186a70]
423
+ - Updated dependencies [a329cca]
424
+ - Updated dependencies [6eec18c]
425
+ - Updated dependencies [4d7bebf]
426
+ - Updated dependencies [821ac7a]
427
+ - Updated dependencies [8f81731]
428
+ - Updated dependencies [4965bfa]
429
+ - Updated dependencies [8b50cb3]
430
+ - Updated dependencies [8c2db68]
431
+ - Updated dependencies [22b5e54]
432
+ - Updated dependencies [0166bd5]
433
+ - Updated dependencies [9b702dc]
434
+ - Updated dependencies [ab16331]
435
+ - @objectstack/spec@17.0.0-rc.1
436
+ - @objectstack/platform-objects@17.0.0-rc.1
437
+ - @objectstack/core@17.0.0-rc.1
438
+ - @objectstack/metadata-core@17.0.0-rc.1
439
+ - @objectstack/formula@17.0.0-rc.1
440
+ - @objectstack/types@17.0.0-rc.1
441
+
442
+ ## 17.0.0-rc.0
443
+
444
+ ### Minor Changes
445
+
446
+ - 14252d3: feat(approvals): cross-organization approver targeting — a plant document can
447
+ require a group-side sign-off (ADR-0105 D9)
448
+
449
+ One organization id used to decide three different things at once in
450
+ `openNodeRequest`: where the request row lives, where its inbox index rows
451
+ live, and **where its approvers are looked up**. The first two are the
452
+ request's own organization by definition. The third is not — a group CFO holds
453
+ her `cfo` position in the GROUP organization while the purchase order she signs
454
+ off lives in the PLANT organization. `expandPositionUsers('cfo', <plant>)`
455
+ matched nobody, the slot fell back to the dead `position:cfo` literal, and a
456
+ group escalation could not be expressed at all.
457
+
458
+ An approver may now declare which organization's directory resolves it:
459
+
460
+ ```yaml
461
+ approvers:
462
+ - { type: position, value: plant_manager, group: plant }
463
+ - { type: position, value: cfo, organization: $root, group: finance }
464
+ behavior: per_group
465
+ ```
466
+
467
+ - **`$root` / `$parent`** walk D6's `parent_organization_id` tree, so the two
468
+ common intents need **no deployment knowledge** — flow metadata is portable
469
+ across environments while organization ids are minted per deployment. A slug
470
+ covers what the symbols cannot, notably a **sibling** organization (a
471
+ shared-services centre approving payables for every plant).
472
+ - Declared **per approver**, so one node can require a plant manager and a
473
+ group CFO in parallel. A node-level form cannot express that without
474
+ splitting into serial nodes, which changes the semantics.
475
+ - **Bounded, not free:** the target must share a `parent_organization_id` root
476
+ with the request's organization. The rule reads only the organization tree —
477
+ never the submitter — so one flow routes identically for everyone.
478
+
479
+ Everything else fails loudly rather than quietly:
480
+
481
+ - a non-`group` posture **refuses** the declaration (a `group` → `isolated`
482
+ migration must not silently reroute approvals);
483
+ - an approver type with no org-scoped directory (`user` / `field` / `manager` /
484
+ `team`) refuses it too, and a new `approval-approver-cross-org-unsupported`
485
+ lint catches that at author time;
486
+ - a targeted approver holding no membership in the request's organization is
487
+ dropped with a warning naming them — D2's union wall would otherwise hide the
488
+ request from someone already routed to, so the node's existing
489
+ `onEmptyApprovers` policy takes over instead of leaving an unopenable task.
490
+
491
+ Nothing changes for an approver without `organization`: same resolution, same
492
+ queries, no extra reads.
493
+
494
+ - f92096b: fix(approvals): an approval action is recorded against the authenticated caller, never a body field (#3800)
495
+
496
+ Every mutating approvals entrypoint takes an `actorId`, and the REST routes
497
+ filled it from `body.actorId ?? body.actor_id ?? context.userId` — so the body
498
+ won. The service then authorized _that value_: `pending_approvers.includes(
499
+ input.actorId)` for a decision, `submitter_id === actorId` for a recall. It never
500
+ checked that the value named the caller.
501
+
502
+ So any authenticated user could POST `{"actorId": "<someone else>"}` and have
503
+ that person's approval recorded, the request finalized, and the owning flow run
504
+ resumed down the `approve` edge — or name a request's submitter and recall it.
505
+ With `api.requireAuth` unset the anonymous-deny never fires either, so an
506
+ unauthenticated request could do the same.
507
+
508
+ #3783 drew this line for the _data-write_ identity and called the audit-row half
509
+ "tolerable". It was not: the same unchecked string was the authorization key, so
510
+ naming someone else was not a mislabelled audit row, it was how you got through
511
+ the door.
512
+
513
+ The actor is now resolved server-side (`ApprovalService.resolveActor`) on all
514
+ nine entrypoints — `decide` / `decideNode`, `recall`, `sendBack`, `resubmit`,
515
+ `reassign`, `remind`, `requestInfo`, `comment`.
516
+
517
+ **The rule is not "`actorId` must equal `context.userId`."** A slot can
518
+ legitimately be keyed by something else: the approver resolver stores the
519
+ `type:value` literal when a graph lookup finds no holders, and the Console picks
520
+ from the caller's own identity list — user id, email, or `role:<r>`. The rule is
521
+ **"the actor must be an identity the server can prove belongs to the caller"**:
522
+
523
+ - A **system** context keeps its explicit actor. The SLA sweep's reserved
524
+ `system:sla` sentinel and the ADR-0043 action link — whose single-use hashed
525
+ token binds exactly one approver — are unchanged. They are the only callers
526
+ holding a trustworthy actor with no session behind them.
527
+ - A caller with **no identity at all** is now refused. This is the anonymous case
528
+ above.
529
+ - **No `actorId`, or one naming the caller**, resolves to the caller. This is the
530
+ common path and what the Console already sends.
531
+ - **Any other value** is accepted only when the server can prove the caller holds
532
+ it — `position:<p>` / `role:<p>` against the positions on the resolved authz
533
+ context, or the caller's own email (one lazy `sys_user` read, taken only when
534
+ nothing cheaper matched). Otherwise `FORBIDDEN`.
535
+
536
+ REST still forwards the body value; it is now a _hint_ the service validates,
537
+ which is what keeps the email and `type:value` slot cases working.
538
+
539
+ **Upgrade note.** A client that deliberately sent another user's `actorId` now
540
+ gets `403 FORBIDDEN` instead of silently succeeding. Send the action as the
541
+ acting user's own session — the field can be omitted entirely, and the caller is
542
+ used. Server-to-server callers that legitimately act for someone else should
543
+ present a system context, as the SLA sweep and the action link already do.
544
+
545
+ This also makes two existing claims true that were previously aspirational: the
546
+ approval object's declared actions say "`actorId` defaults to the caller
547
+ server-side… the service remains the authority on who may act", and
548
+ `attachViewers` documents `can_act` as mirroring "the exact authorization the
549
+ decision methods enforce".
550
+
551
+ - fb90784: fix(approvals): the status mirror names the human who caused the transition (#3783)
552
+
553
+ When an approval moves, the service writes the new status onto the business
554
+ record (`approvalStatusField`). That write is what fires the record-change flows
555
+ bound to that object — so it is the seam "when the invoice is approved, do X"
556
+ runs through. It presented a bare `{ isSystem: true }` context with **no
557
+ `userId`**, at six call sites that each know exactly who acted: a submitter
558
+ submitting, an approver approving, rejecting, sending back, recalling.
559
+
560
+ Combined with #3760 — which stopped letting a `runAs:'user'` run with no trigger
561
+ user touch data — that identity gap made the most natural approvals automation
562
+ there is unwritable in its obvious form. The cascade inherited no user, so its
563
+ data nodes were refused, and the author's only way forward was to declare
564
+ `runAs: 'system'` and take blanket elevation for a case where a perfectly good
565
+ scoped identity existed at the call site all along.
566
+
567
+ The mirror now carries the acting user. It stays `isSystem` — the record is
568
+ normally locked while its approval is live, so only a platform write can land the
569
+ status — because elevation and anonymity are separate choices, and this write
570
+ only ever needed the first. Cascades now run as the deciding user with RLS
571
+ enforced.
572
+
573
+ - **The identity is the authenticated principal, never the request body's
574
+ `actorId`.** `actorId` arrives from the caller (`body.actorId ?? context.userId`)
575
+ and is only checked against the pending approver slate, never against the
576
+ caller. That is tolerable on an audit row; promoting it to the identity of an
577
+ RLS-scoped write would have turned a mislabelled audit trail into identity
578
+ spoofing.
579
+ - **Approval-by-email-link is attributed too.** ADR-0043 action links carry no
580
+ session, so they used to decide as pure system. The single-use hashed token
581
+ binds exactly one approver and is re-checked against the live slate at
582
+ redemption — that is an authentication — so the redeemed decision now presents
583
+ that approver, and an emailed approval cascades identically to one made in the
584
+ UI.
585
+ - **The two machine-driven transitions stay user-less on purpose**: the SLA
586
+ escalation's auto-decision and the dead-run sweep. `system:sla` and
587
+ `system:dead-run` are reserved audit actors, not users, and presenting one as a
588
+ user would put a non-user in `updated_by` and in every downstream flow's
589
+ identity. A flow that wants to react to those declares `runAs:'system'` — the
590
+ honest answer, and now a deliberate one rather than an artefact.
591
+ - **Attribution only — the write is not newly org-scoped.** On an
592
+ ExecutionContext `tenantId` is a driver-scoping knob, not attribution
593
+ (ObjectQL turns it into a tenant predicate), so passing the request's org would
594
+ have silently no-op'd the mirror on a record whose org differs. The automation
595
+ engine already back-fills a run's `tenantId` from the resolved user's grants.
596
+
597
+ **Visible change:** the mirrored record's `updated_by` now names the acting user
598
+ instead of retaining its previous value — ObjectQL's audit stamping is gated on
599
+ the write context's `userId` alone, and `isSystem` buys no exemption. That is the
600
+ attribution this fix is for: the approver who set the record to `approved` is now
601
+ its last modifier.
602
+
603
+ - a6c3f38: feat(approvals): expose the pending node's `lockRecord` policy on the request row (#3814, objectui#2902)
604
+
605
+ An approval node declares `lockRecord` (default `true`), and the record-lock
606
+ `beforeUpdate` hook enforces exactly that: `lockRecord: false` and the record
607
+ stays writable for the whole time the node waits. The behavior was correct and
608
+ has been since Phase B — but it was **invisible to every client**.
609
+
610
+ `rowFromRequest` parses `node_config_json` and projects a whitelist out of it
611
+ (`__flowLabel`, `__nodeLabel`, `__round`, `escalation.timeoutHours`,
612
+ `decisionOutputs`). `lockRecord` was never in that list, and no other field on
613
+ `ApprovalRequestRow` carried the lock either. So the strongest thing a console
614
+ could learn from `GET /approvals/requests` was _"a pending request exists"_ —
615
+ from which it can only assume the record is locked.
616
+
617
+ That assumption is wrong on every opted-out node, and a flow that chains nodes
618
+ with different policies makes it visibly wrong: the same UI state renders for
619
+ "you may edit this" and "the server will reject your save with `RECORD_LOCKED`".
620
+ The console has no third option — guessing the other way would offer an edit
621
+ that dies on save.
622
+
623
+ `ApprovalRequestRow` now carries **`lock_record: boolean`**, read from the same
624
+ snapshot the hook reads, with the same `!== false` default. Present on every
625
+ service read (`openNodeRequest` / `getRequest` / `listRequests`), so the flag a
626
+ client renders and the rule the server applies cannot drift.
627
+
628
+ Additive and backward compatible — nothing to migrate. A client that wants
629
+ node-accurate lock state reads `request.lock_record`; treat `undefined` (an
630
+ older backend) as locked, which is the pre-existing behavior.
631
+
632
+ The showcase's `showcase_budget_approval` now declares `lockRecord: false` on
633
+ its single-approver Manager Review and keeps `true` on the multi-approver
634
+ Executive Review, so both policies are exercised in one flow.
635
+
636
+ - 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.
637
+
638
+ 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.
639
+
640
+ - 57a3bb3: fix(automation,approvals): the run-resume route is gated by the node the run is parked on (#3801)
641
+
642
+ `POST /api/v1/automation/:name/runs/:runId/resume` forwarded a caller-supplied
643
+ `{ inputs, output, branchLabel }` straight into `AutomationEngine.resume`, and
644
+ `resumeInternal` validated **machine state only** — the concurrent-resume latch,
645
+ the run exists, the flow exists, the suspended node still exists. Nothing asked
646
+ _who was calling_.
647
+
648
+ Approval nodes suspend and resume through exactly that mechanism. So a resume
649
+ carrying `branchLabel: 'approve'` walked the approve edge with **no approver
650
+ check, no `sys_approval_action` row and no status mirror** — the
651
+ `sys_approval_request` row and the run then disagreed permanently. The only
652
+ thing standing between the route and the approvals rules was convention; the
653
+ showcase spelled it out in a comment ("decide via the approvals API, never a raw
654
+ engine `resume`"), and a comment in an example is not an access control.
655
+
656
+ Removing the route was not the fix: it is load-bearing for **screen flows** —
657
+ the UI flow-runner posts `{ inputs }` there to advance a paused `screen` node.
658
+ The gate therefore keys on **what the run is parked on**:
659
+
660
+ - `ActionDescriptor.resumeAuthority` (`'any'` | `'service'`, default `'any'`) —
661
+ a pausing node declares who may continue it. `approval` declares `'service'`.
662
+ - The engine refuses a `'service'` suspension unless the signal carries
663
+ `RESUME_AUTHORITY_SERVICE` (`@objectstack/spec/contracts`), a **symbol** the
664
+ owning service stamps in-process — a JSON body can never produce one, so the
665
+ transport cannot forge it. `ApprovalService` stamps it on the tail of a
666
+ decision it has already authorized and recorded.
667
+ - The gate follows a **subflow** pause down to the child the signal would
668
+ actually reach, so resuming the parent is not a way around it.
669
+ - Refusal returns `{ success: false, code: 'forbidden' }` and the route answers
670
+ **403**. Nothing is consumed — the request stays pending and the run stays
671
+ parked, so the real decision still lands.
672
+
673
+ `screen` and `wait` pauses are unchanged, as is every path that already went
674
+ through the approvals API. What changes for consumers:
675
+
676
+ - **FROM:** finishing an approval with
677
+ `client.automation.resume(flow, runId, { branchLabel: 'approve' })`
678
+ **TO:** `client.approvals.approve(requestId, …)` (or `.reject` / `.recall`).
679
+ The old call now answers 403 and changes nothing.
680
+ - Registering your own pausing node whose continuation belongs to a service
681
+ rather than to whoever holds the run id? Declare `resumeAuthority: 'service'`
682
+ on its descriptor and stamp `RESUME_AUTHORITY_SERVICE` on the signal from that
683
+ service.
684
+
685
+ A suspension now records the node type that produced it
686
+ (`SuspendedRun.nodeType` / `sys_automation_run.node_type`), captured at suspend
687
+ time so a flow republished mid-pause cannot re-type the node out from under the
688
+ gate; rows written before this fall back to the flow definition.
689
+
690
+ - 2fa4ca1: Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities:
691
+
692
+ **`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.
693
+
694
+ **`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.
695
+
696
+ **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).
697
+
698
+ 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.
699
+
700
+ - 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.
701
+
702
+ ### Patch Changes
703
+
704
+ - d058594: fix(approvals): refuse `organization` on directory-less approver types instead
705
+ of silently ignoring it (ADR-0105 D9)
706
+
707
+ `user`, `field` and `manager` return EARLY in `resolveApproverSpec` — they name
708
+ a person outright rather than expanding a directory. D9's org resolution was
709
+ placed after those returns, so an `organization` declared on one of them never
710
+ reached the check: it was silently INERT.
711
+
712
+ That is the one behaviour ADR-0105 D9 rules out and the authoring docs
713
+ explicitly promise against ("`organization` on those is refused at runtime").
714
+ The `os lint` rule caught it at author time, but the runtime claim was false —
715
+ and a stored flow that predates the lint, or one assembled programmatically,
716
+ got no signal at all.
717
+
718
+ Resolution now happens at the top of `resolveApproverSpec`, above every early
719
+ return, so the refusal reaches all three types. The ordinary path is unchanged
720
+ and still costs nothing: with no `organization` declared the resolver returns
721
+ the request's organization without reading anything.
722
+
723
+ Found by cloud's group-posture dogfood driving a real `group` boot — the
724
+ resolver's own unit tests could not see it, because they call the resolver
725
+ directly and never traverse the early return.
726
+
727
+ - 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a
728
+ first-class authorization dimension.
729
+
730
+ > This release carries BREAKING spec removals (see "Enforce-or-remove" below)
731
+ > but is recorded as `minor`: every publishable package is in the Changesets
732
+ > lockstep group, so one `major` would promote the whole monorepo. Breaking
733
+ > changes ship as `minor` during the launch window — the migration notes below
734
+ > are what reach consumers in `CHANGELOG.md`.
735
+
736
+ ## Tenancy is now a spectrum (D1)
737
+
738
+ `single | group | isolated`, resolved by the `tenancy` service and selected with
739
+ the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged:
740
+ `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED`
741
+ (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot
742
+ rather than silently landing in a posture with no organization wall.
743
+
744
+ - `single` — no wall (unchanged).
745
+ - `group` — **new.** Organizations are membership boundaries over one shared
746
+ dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC
747
+ semantics). Enforced by the OPEN engine.
748
+ - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping`
749
+ probe and degraded-boot handling all unchanged.
750
+
751
+ ## Organization scope is a first-class context field (D2)
752
+
753
+ `ExecutionContext.accessible_org_ids` — every organization the caller holds a
754
+ currently-valid membership in (ADR-0091 validity windows) — is resolved once by
755
+ `resolveAuthzContext` and carried by every transport. The `group` wall reads it
756
+ directly; RLS policies may reference it as
757
+ `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set
758
+ fails the wall closed.
759
+
760
+ Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still
761
+ computed independently of the RLS compiler, AND-composed outermost, and
762
+ crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so
763
+ ADR-0095's W1/W2 invariants hold in every posture.
764
+
765
+ ## Two P0 correctness fixes (D3, D4) — behavior changes
766
+
767
+ **D3 — app-authored org-scoped RLS policies are no longer silently dropped**
768
+ (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose
769
+ `using` contained the substring `current_user.organization_id` when isolation was
770
+ inactive, which swallowed app-authored policies as well as the platform's own.
771
+ Stripping is now decided by PROVENANCE (identity against the shipped
772
+ declaration). **Upgrade impact:** in a deployment with no organization wall, an
773
+ app-authored policy referencing the active organization is now RETAINED and
774
+ fails closed (zero rows) with a one-time warning, where it previously vanished
775
+ and the object read unscoped. `getReadFilter` shared the defect, so analytics and
776
+ raw-SQL consumers were affected too. If a policy was only ever meant for
777
+ multi-org, delete it or install `@objectstack/organizations`.
778
+
779
+ **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization
780
+ boundary** (finding F2, framework#3540). Under a wall-less posture nothing
781
+ bounded the wildcard superuser bits `organization_admin` carries, so a
782
+ deployment that accumulated organizations (personal orgs on signup) made every
783
+ owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a
784
+ de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and
785
+ revokes the superseded variant whenever the posture changes. **Upgrade impact:**
786
+ in `single` posture an org owner/admin keeps full CRUD but loses the blanket
787
+ ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains
788
+ available through `admin_full_access` or an explicitly authored permission set —
789
+ it just stops being a side effect of a better-auth membership role.
790
+
791
+ ## Engine-owned organization stamping (D5)
792
+
793
+ Under any wall-enforcing posture the engine stamps `organization_id` from the
794
+ caller's active organization on an insert that omits it, and validates every
795
+ supplied value against the wall. Idempotent with the enterprise auto-stamp
796
+ (neither overwrites a supplied value). This also closes a real hole: the
797
+ pre-existing post-image check required a non-array payload, so a BULK insert
798
+ could carry a forged `organization_id` per row. One forged row now denies the
799
+ whole write.
800
+
801
+ ## Group structure, extension fields and red-line lints (D6, D7)
802
+
803
+ - `sys_organization` gains `parent_organization_id` and `sort_order` — a
804
+ **reporting dimension only**.
805
+ - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`,
806
+ `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` /
807
+ `os validate`: an RLS policy or sharing rule that walks the org tree is an
808
+ error, as is a business-unit grant on a platform-global object.
809
+ - Extension fields on better-auth-managed objects ride the existing ADR-0092
810
+ whitelist. A new guard derives better-auth's real field surface from
811
+ `getAuthTables()` at the pinned version and fails the build on any name
812
+ collision, so a library upgrade cannot silently take ownership of a column.
813
+
814
+ ## Enforce-or-remove (D11) — BREAKING
815
+
816
+ Both removals are of surface that had **zero runtime consumers**, so no
817
+ behavior changes; authoring them is now a no-op instead of a lint warning.
818
+
819
+ - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read
820
+ it. FROM → TO: a set a policy needs as `field IN (current_user.<key>)` is now
821
+ supplied by a registered membership resolver (below); a constant belongs in
822
+ the policy itself as a literal (`status = 'published'`).
823
+ - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`)
824
+ — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO:
825
+ matrix requirements are served by multi-position × business-unit anchoring; a
826
+ generalized dimension-security module will arrive with its own ADR.
827
+ - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has
828
+ merged since ADR-0056 finally has a producer: register an
829
+ `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the
830
+ `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by
831
+ construction — an unresolved key makes its policies drop out. Kernel-owned
832
+ keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be
833
+ overwritten from this seam.
834
+
835
+ ## Edition boundary (D12)
836
+
837
+ The `group` posture's enforcement primitives ship OPEN — the union wall,
838
+ `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness
839
+ fixes and the D6 lints — because the correctness of a wall is never a paid
840
+ feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing
841
+ enterprise `org-scoping` probe, so the current commercial boundary for
842
+ legal-entity isolation is unchanged by this release.
843
+
844
+ - 2ba560a: fix(plugin-approvals): give the decision actions a visual hierarchy (objectui#2762 P1-5)
845
+
846
+ The `sys_approval_request` decision actions all declared as equal-weight
847
+ buttons, so the drawer's action bar rendered five identical outlined
848
+ buttons with no emphasis on the primary path. `approval_approve` now
849
+ declares `variant: 'primary'` and `approval_reject` declares
850
+ `variant: 'danger'`, so a metadata-driven renderer highlights Approve and
851
+ styles Reject as destructive — matching the hierarchy the mobile card
852
+ already has. Pure metadata; the secondary levers stay unstyled (tertiary).
853
+
854
+ - 2dda6e7: fix(plugin-approvals): localize the declared decision-action labels (objectui#2762 P0-3)
855
+
856
+ The Approval Center's decision drawer rendered the `sys_approval_request`
857
+ declared actions with their literal metadata labels — English **Approve /
858
+ Reject / Reassign / Send back / Request info** in a zh-CN workspace, sitting
859
+ next to the same page's localized 通过 / 拒绝 inbox buttons. The plugin's
860
+ translation bundle covered fields and views but had no `_actions` node, so
861
+ the console's `_actions.<name>.label` resolution had nothing to hit.
862
+
863
+ - Re-ran `os i18n extract` against the plugin's config: the bundles now carry
864
+ `_actions` translations (label, confirmText, successMessage, param labels
865
+ and helpText) for all eight decision actions — `approval_approve`,
866
+ `approval_reject`, `approval_reassign`, `approval_send_back`,
867
+ `approval_request_info`, `approval_remind`, `approval_recall`,
868
+ `approval_resubmit` — in zh-CN, ja-JP and es-ES (en keeps the metadata
869
+ literals).
870
+ - The extract also surfaced other untranslated gaps, now filled in all three
871
+ locales: the `returned` status option, the `sys_approval_action.action`
872
+ audit options (`reassign` / `remind` / `request_info` / `comment` /
873
+ `revise` / `resubmit` / `ooo_substitute`), the `attachments` field, and the
874
+ `my_pending` / `recent` view empty states.
875
+
876
+ - 474fe39: feat(approvals): declare approver value bindings; retire `queue` approver authoring (#3508)
877
+
878
+ - `@objectstack/spec` exports `APPROVER_VALUE_BINDINGS` — the single declaration of how a
879
+ designer must source each approver row's `value`: `user`/`team`/`department`/`position`
880
+ are DATA-record lookups on the system directory objects (`sys_user` / `sys_team` /
881
+ `sys_business_unit` / `sys_position`; `position` commits the machine **name**, the
882
+ others the row id), `org_membership_level` is a closed enum (`ORG_MEMBERSHIP_LEVELS`),
883
+ `manager` is auto-resolved, `field` names a trigger-object field, and `queue` is
884
+ unsupported. Also exports `NON_AUTHORABLE_APPROVER_TYPES`.
885
+ - `queue` approver type is deprecated-for-authoring: it still parses (stored flows keep
886
+ loading and rendering) but is published in `xEnumDeprecated`, so designers stop
887
+ offering it — the runtime has no queue resolution and the slot routes to nobody. The
888
+ approver `value` xRef now also maps `manager`, so designers can render its
889
+ auto-resolved state. No authored key is removed; nothing to migrate. If a flow carries
890
+ `{ type: 'queue' }`, replace it with `team` / `department` / `position` (or a concrete
891
+ `user`) until a real ownership-queue implementation lands.
892
+ - `@objectstack/plugin-approvals` now warns at resolution time when a stored `queue`
893
+ approver is skipped.
894
+ - `@objectstack/lint` adds `approval-approver-type-unsupported` (warning) for approver
895
+ types that are declared but not implemented by the runtime.
896
+
897
+ - 0bc685a: fix(approvals): return decision attachments as file values, not "[object Object]" (#3504)
898
+
899
+ `sys_approval_action.attachments` is a `Field.file`, so the column **stores an
900
+ opaque `sys_file` id** (ADR-0104 D3 — the stored form of every media field). The
901
+ ObjectQL read path resolves that id into its expanded
902
+ `{ id, name, size, mimeType, url }` form on the way out. But `rowFromAction`
903
+ mapped the column with `.map(String)`, collapsing each expanded value to the
904
+ literal string `"[object Object]"`. Every `listActions` consumer (the approval
905
+ inbox timeline) then received garbage: the attachment chip had no filename and
906
+ its id was `"[object Object]"`, so opening it 404'd.
907
+
908
+ - `ApprovalActionRow.attachments` is now `ApprovalActionAttachment[]` — the
909
+ expanded file value plus its id, so a consumer can label and open an
910
+ attachment without needing read access to the system `sys_file` object (which
911
+ regular approvers do not have).
912
+ - Three read forms are accepted: the expanded value (the normal case), a bare id
913
+ (nothing to expand it into — storage service absent, file not committed), and
914
+ a legacy inline blob written before file-as-reference (`file_id` /
915
+ `mime_type`), until the backfill converts it. The id test reuses the
916
+ platform's `isFileIdToken`, so this and the engine's read resolver cannot
917
+ disagree about what counts as an id.
918
+ - The decision _input_ (`ApprovalDecisionInput.attachments`) is unchanged — it
919
+ still takes fileId strings, which is also exactly what the column stores. Only
920
+ the read shape changed.
921
+
922
+ - b949059: fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456)
923
+
924
+ The record lock is keyed on a **pending** `sys_approval_request`, and it could
925
+ not tell _the run that owns that request_ from _an unrelated user editing the
926
+ record_. So a flow that touched its own target record while its own approval was
927
+ still pending — a manual `resume` with no decision, or a node that writes the
928
+ record between opening the approval and the decision — died on its own
929
+ `RECORD_LOCKED`, and the record stayed locked behind the dead run. Recovery
930
+ existed (#3424 lets an admin `recall`/`reject` to release it) but nothing made it
931
+ self-healing.
932
+
933
+ Both halves are now closed.
934
+
935
+ **Prevention — the owning run may write its own record.** The automation engine
936
+ stamps `flowRunId` onto the run context at setup, alongside `runAs`, and it
937
+ travels with every data node's ObjectQL context into `ctx.provenance`. The lock
938
+ hook exempts a write whose `flowRunId` matches the pending request's `flow_run_id`.
939
+ It is keyed on run identity rather than elevation on purpose: a `runAs:'user'`
940
+ run stays fully RLS-scoped while it writes. `flowRunId` is pure provenance —
941
+ server-constructed like `isSystem`, never client-supplied, evaluated by no
942
+ security middleware, and the only write it permits is to the one record its own
943
+ run already holds a pending request against.
944
+
945
+ **Recovery — a sweep releases records held by runs that died anyway.** A pending
946
+ request whose owning run has reached a terminal state (`completed`, `failed`,
947
+ `cancelled`, `timed_out`) can never be decided, so it is finalised as `recalled`
948
+ — releasing the lock — and audited under the reserved actor `system:dead-run`
949
+ with the run and its status in the comment, so it is never mistaken for a
950
+ submitter's withdrawal. It runs on the existing approvals sweep clock, which also
951
+ covers the case no in-band handler can: a run killed by a process crash.
952
+
953
+ The sweep is fail-safe by construction. It acts only on an explicit terminal
954
+ status from a closed set; `paused` (the normal state of a live approval),
955
+ `running`, an unrecognised status, an unknown run, a `getRun` that throws, and a
956
+ deployment with no automation engine are all read as "still alive". The failure
957
+ mode is "a dead run's lock survives until an admin recalls it" — today's
958
+ behaviour — never "a live approval is destroyed".
959
+
960
+ Also fixes `AutomationEngine.getRun`, which returned the **first** log entry for
961
+ a run id rather than the latest. A run that pauses and later finishes records two
962
+ entries under one id, so every suspend-then-finish run — every approval, screen
963
+ and wait flow — reported itself as `paused` forever, both on the Runs
964
+ observability surface and to this sweep.
965
+
966
+ One shape was left out here and closed separately in #3712: a `runAs:'user'` run
967
+ with no trigger user (a schedule) resolved no ObjectQL context at all, so it
968
+ carried no `flowRunId` and stayed subject to the lock. It now passes a
969
+ provenance-only context — the run id and nothing the security middleware keys on
970
+ — so it is attributable without acquiring a principal, and its documented
971
+ unscoped posture (#1888) is unchanged.
972
+
973
+ - be1c52c: fix(approvals): admin override for a request routed to an unstaffed approver (#3424)
974
+
975
+ An `approval` node routed to a `position` (or `team`/`department`) with **no
976
+ holders** resolved to only the unresolvable `position:<name>` literal in
977
+ `pending_approvers` — no concrete user was in the slate. Every normal
978
+ `decide` / `reassign` / `recall` then returned `FORBIDDEN` (not a pending
979
+ approver) and, with `lockRecord`, the target record stayed `RECORD_LOCKED`
980
+ forever: a data-availability dead-end with no in-product recovery (the only exit
981
+ was editing the DB by hand). Very easy to hit in fresh/demo orgs (positions
982
+ seeded, holders not) and whenever a role is vacated in production.
983
+
984
+ A **platform or tenant admin** — the same posture the engine's superuser bypass
985
+ already trusts — may now act on any _pending_ request to release it: **approve,
986
+ reject, reassign** it to a real approver, or **recall** it. The override finalizes
987
+ the request (which releases the record lock, keyed on a pending request); a
988
+ tenant admin's authority is org-scoped, a platform admin's is not, and the
989
+ decision is audited under the admin's own id. An admin approval is authoritative,
990
+ finalizing the node even under `unanimous` / `quorum` / `per_group` rather than
991
+ counting as one vote among the (empty) slate.
992
+
993
+ - `sys_approval_request.viewer` gains `can_override` (server-computed): true for a
994
+ privileged admin on a pending request. The `approve` / `reject` / `reassign`
995
+ declared actions OR it into their `visible` gate, so the console surfaces the
996
+ recovery path without a hand-wired button. Existing approver/submitter gating is
997
+ unchanged.
998
+ - `openNodeRequest` now logs a loud warning when a node resolves to **no concrete
999
+ approver**, so the misconfiguration is visible instead of silently locking the
1000
+ record. The literal-fallback behavior (kept for 15.x slot back-compat) is
1001
+ otherwise unchanged.
1002
+
1003
+ - c5ff96d: fix(approvals): a schedule-triggered run can write its own locked record (#3712)
1004
+
1005
+ #3456 let the run that opened a pending approval write its own target record,
1006
+ keyed on `flowRunId`. It worked for every run that resolves an identity and
1007
+ missed the one that doesn't: an effective `runAs:'user'` run with **no trigger
1008
+ user** — a schedule being the canonical case — passed no ObjectQL context at
1009
+ all, so nothing carried the run id and the run still died on its own
1010
+ `RECORD_LOCKED`.
1011
+
1012
+ The blocker was never the lock. It was that "no identity" and "no context" were
1013
+ the same thing on the wire, so a run could not say _who it was_ without also
1014
+ claiming _what it was allowed to do_.
1015
+
1016
+ **A run with no principal now passes provenance alone.**
1017
+ `resolveRunDataContext` returns `{ flowRunId }` — no `userId`, no `positions`,
1018
+ no `permissions`, not even `isSystem: false`. Every principal gate keys on one
1019
+ of those fields (the elevation short-circuit on `isSystem`, the ADR-0103
1020
+ engine-owned write guard and the ADR-0090 D12 delegated-admin gate on `userId`,
1021
+ the empty-principal fall-open on all three), so this context authorizes
1022
+ **identically to no context at all**. The run keeps the documented #1888
1023
+ unscoped posture, its loud `[runAs]` warning, and the
1024
+ `flow-schedule-runas-unscoped` build-time lint. Nothing about what it may touch
1025
+ changed — only that it can now be attributed.
1026
+
1027
+ **Provenance moved out of the hook session, into `ctx.provenance`.** `session`
1028
+ answers _who is calling_ and is absent when no identity envelope was supplied —
1029
+ a distinction real gates depend on (the attachment access gate skips bare-kernel
1030
+ writes on exactly that test). Folding a run id into `session` would have forced
1031
+ an identity-less run to present an empty session, silently turning "no caller"
1032
+ into "an anonymous caller" and narrowing the #1888 fail-open for attachments
1033
+ alone. `HookContext.provenance.flowRunId` says what produced the write; the
1034
+ approvals lock reads it there.
1035
+
1036
+ Also relaxes `BaseEngineOptionsSchema.context` to a partial envelope
1037
+ (`ExecutionContextInput`). `positions`/`permissions`/`isSystem` carry parse-time
1038
+ defaults, which made them _required_ on a caller-supplied option and asserted
1039
+ something untrue — that every data-engine context carries a principal. Callers
1040
+ have always passed slices (`{ isSystem: true }` for a system read); the type now
1041
+ says so.
1042
+
1043
+ Migration: nothing to change unless you read the run id inside a hook. If you
1044
+ wrote `ctx.session.flowRunId`, read `ctx.provenance.flowRunId` instead — the
1045
+ field never shipped under the old name.
1046
+
1047
+ - d2a8695: fix(approvals)!: an approval request is visible to its participants, not to the whole tenant (#3590)
1048
+
1049
+ `getRequest` / `listRequests` / `countRequests` deliberately query with
1050
+ `SYSTEM_CTX` to bypass RLS — as the code comments say, the approver-visibility
1051
+ rule spans identity forms RLS cannot model cleanly, so it has to be expressed in
1052
+ the service. Only the **tenant** half of that rule was ever applied. The
1053
+ participant half was named in the comment and never written, so **any
1054
+ authenticated user could read any approval request in their tenant** — its
1055
+ payload snapshot, its full decision history, and (once decision attachments
1056
+ derived their access from the request, #3580) its files.
1057
+
1058
+ `approverId` on `listRequests` is a _filter_, not authorization: omitting it
1059
+ returned the whole tenant.
1060
+
1061
+ A caller now sees a request when they are a participant — the submitter, a
1062
+ current approver (via the normalized approver index, so every identity form the
1063
+ write path recorded is covered), or someone who has already acted on it (a past
1064
+ approver whose slot has moved on, a commenter). Admins with override authority
1065
+ keep the unrestricted view the "all requests" console surface depends on, and a
1066
+ tokenless context sees nothing.
1067
+
1068
+ Keying on the concrete user id is sufficient rather than an approximation:
1069
+ position/team/manager/field approvers are resolved to concrete user ids at open
1070
+ time, and the `type:value` literal is only the fallback for a spec that resolved
1071
+ to _nobody_ — a slot no one can act on either way. So this cannot hide a request
1072
+ from someone who could actually act on it.
1073
+
1074
+ **A write path's own result is not re-gated.** Every operation echoes back the
1075
+ request it just changed; the operation already authorized itself, and re-asking
1076
+ would answer wrong for a context carrying no `userId` (a flow-driven resume, a
1077
+ service-to-service call), turning a successful write into `null`.
1078
+
1079
+ Marked breaking because a client that listed requests without an `approverId`
1080
+ filter and expected the whole tenant will now receive only its own — which is
1081
+ the point.
1082
+
1083
+ - 84e7be9: feat(plugin-approvals): expose per-group membership of pending approvers (objectui#2807)
1084
+
1085
+ `per_group` (会签) requests now carry `pending_approver_groups` on the
1086
+ enriched row — a map from each still-pending approver id to the group key(s)
1087
+ it fills (e.g. `{ "u_devadmin": ["finance", "legal"] }`). A client can label
1088
+ each "waiting on" chip with the group it represents instead of showing
1089
+ duplicate, context-free names.
1090
+
1091
+ - Resolved in `attachDecisionProgress` from the same open-time
1092
+ `__approverGroups` snapshot the `decision_progress` groups already use, so
1093
+ the two never disagree.
1094
+ - Only the **pending** slots are mapped (a resolved approver has left
1095
+ `pending_approvers`), and **synthetic** (unnamed, `#N`) group keys are
1096
+ dropped — a `· #0` sub-tag would be noise.
1097
+ - Absent for non-`per_group` behaviors. Display-only; the engine's
1098
+ finalization tally stays authoritative.
1099
+ - Added to the `ApprovalRequestRow` contract in `@objectstack/spec`.
1100
+
1101
+ - debc23a: feat(approvals): enrich inbox rows with `payload_labels` (snapshot field labels)
1102
+
1103
+ The approvals inbox summary title-cased raw snapshot machine keys
1104
+ (`assessment_status` → "Assessment Status") because the API sent no field
1105
+ labels. `ApprovalService.enrichRows` now attaches `payload_labels` (snapshot
1106
+ field key → the target object's field label), symmetric with the existing
1107
+ `payload_display` (which resolves the values), and `ApprovalRequestRow` gains
1108
+ the field. For a single-locale project the schema label is already the
1109
+ localized string, so a client can render the human field name (e.g. "考核状态")
1110
+ instead of a prettified English key.
1111
+
1112
+ - 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)
1113
+
1114
+ Four follow-ups from browser-verifying the #3508 approver work end to end.
1115
+
1116
+ **`APPROVER_VALUE_SOURCES` — the designer stops guessing where candidates live.**
1117
+ `xRef.map` only ever named a picker KIND (`'team'`), never where that picker's
1118
+ rows come from, so the designer carried its own copy of the data contract — and
1119
+ the first copy was wrong: every directory kind was wired to `GET
1120
+ /api/v1/meta/:type`, the metadata REGISTRY, which does not hold `sys_user` /
1121
+ `sys_team` / `sys_business_unit` / `sys_position` rows. Candidates came back
1122
+ empty and the control degraded to free text (#3508). The binding is now
1123
+ projected onto the published JSON schema as `xRef.sources` — `{ source: 'data',
1124
+ object, valueField }` for the record-backed kinds, the closed enum inline for
1125
+ `org_membership_level` — derived from `APPROVER_VALUE_BINDINGS` so the two
1126
+ cannot drift, and inheriting its `satisfies` exhaustiveness (a new
1127
+ `ApproverType` member that declares no source is a compile error). Presentation
1128
+ — which field to show, whether to open a people-picker, what subtitle to use —
1129
+ stays a renderer decision.
1130
+
1131
+ **`ApproverType` declaration order is now the authoring recommendation.**
1132
+ objectui#2834 argued for leading with indirect bindings and shipped that order
1133
+ in its own options array — which the Studio inspector never reads: it derives
1134
+ the picker from this enum via the published schema, so `user` still came first.
1135
+ The intent only takes effect if the enum carries it, so the enum now reads
1136
+ `manager, position, department, team, field, expression, org_membership_level,
1137
+ user` (deprecated `role` / `queue` still parse and stay out of every picker via
1138
+ `xEnumDeprecated`). Binding one specific person is the least portable choice an
1139
+ author can make — it breaks when the flow moves to another environment (that id
1140
+ does not exist there) and again when that person leaves.
1141
+
1142
+ **A graph approver that expands to nobody no longer does it in silence.**
1143
+ `queue` already warned (#3508); every OTHER graph type — `team`, `department`,
1144
+ `position`, `org_membership_level`, `manager` — fell back to the same
1145
+ unactionable `type:value` literal without a word. That silence is what let
1146
+ #3807 hide for as long as it did: the request opened with an empty slate and
1147
+ the first symptom was a permanently stuck approval (#3424). The fallback stays
1148
+ (15.x slots and substring fixtures depend on it); it now logs the type, value
1149
+ and organization that produced it. `user` / `field` stay quiet — they take the
1150
+ id they were given and never had an "expanded to nobody" state.
1151
+
1152
+ **`plugin-sharing`'s identical org scope is pinned by tests.**
1153
+ `BusinessUnitGraphService.orgScope` has the same strict `organization_id`
1154
+ equality #3807 fixed in approvals. It is unreachable today — every materialized
1155
+ `sys_sharing_rule` carries `organization_id = null`, so the filter is skipped —
1156
+ and widening an authorization path on a defect that cannot currently fire is
1157
+ not a change to make blind. New tests lock both the reachable paths and the
1158
+ divergence itself, so if sharing ever adopts the null-org=env-wide reading it
1159
+ is a deliberate edit to a named test rather than a silent behaviour change.
1160
+
1161
+ - 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.
1162
+ - 3ea7271: fix(approvals): a `department` approver resolves against env-wide business units (#3807)
1163
+
1164
+ `expandBusinessUnitUsers` scoped its `sys_business_unit` reads with a strict
1165
+ `organization_id = <request org>` equality, so a unit whose `organization_id`
1166
+ is `null` was invisible: the seed check found no row, the expansion returned
1167
+ `[]`, and the approver fell back to the dead `department:<id>` literal that
1168
+ routes to nobody.
1169
+
1170
+ That is the normal case, not an edge case. An app's org tree is seeded, and a
1171
+ seed cannot know the organization id the runtime mints at boot, so every seeded
1172
+ unit carries `organization_id = null` — while an approval request always
1173
+ carries an org. Every business unit a flow author could pick therefore resolved
1174
+ to nobody, silently: the request opens, the slate is empty, and (with
1175
+ `lockRecord`) the record stays locked with no one able to act (#3424 is the
1176
+ downstream shape of the same dead end). Verified against a live showcase stack:
1177
+ a `{ type: 'department', value: 'bu_hq_finance' }` approver produced
1178
+ `pending_approvers: "department:bu_hq_finance"` while the unit's member sat
1179
+ right there in `sys_business_unit_member`.
1180
+
1181
+ Both the seed check and the subtree descent now scope to **this org ∪
1182
+ env-wide** — `$or: [{ organization_id: <org> }, { organization_id: null }]` —
1183
+ the same predicate `sys_metadata`'s pending-draft listing settled on for the
1184
+ identical reason (a strict equality silently dropping env-wide rows). The wall
1185
+ between two organizations is unchanged: another org's unit still fails the
1186
+ match, and a null-org parent does not drag another org's child unit into the
1187
+ subtree.
1188
+
1189
+ Note the same strict-equality scope exists in `plugin-sharing`'s
1190
+ `BusinessUnitGraphService.orgScope`. It is not reachable today — every
1191
+ materialized `sys_sharing_rule` row carries `organization_id = null`, so the
1192
+ filter is skipped — and is left alone here rather than widen an
1193
+ access-granting path on a defect that cannot currently fire.
1194
+
1195
+ - deb538f: fix(storage): let an object delegate file-read authorization to its service
1196
+
1197
+ Fixes a regression from the governed-download change (ADR-0104 D3 wave 2): a
1198
+ **legitimate approver could see a decision attachment's filename but got 403
1199
+ opening it**, found by driving app-showcase in a browser as a real non-admin
1200
+ approver.
1201
+
1202
+ Cause: a field-owned file's download was authorized by testing whether the
1203
+ caller can READ the owning row. For an ordinary business object that is right —
1204
+ row readability _is_ the access rule. For `sys_approval_action` it is the wrong
1205
+ authority: the audit table is deliberately closed to ordinary approver
1206
+ positions (`operation 'find' … is not permitted for positions [auditor,
1207
+ everyone]`), so the test denied the very approver the attachment was filed for.
1208
+ The approvals _service_ has always had the real rule, which is why the timeline
1209
+ listing the attachment returned 200 while the bytes returned 403.
1210
+
1211
+ An object may now name a service to answer the question instead:
1212
+
1213
+ - `ObjectSchema.fileAccessDelegate` — a kernel service that authorizes
1214
+ downloads of files owned by that object's media fields.
1215
+ - `IFileAccessDelegate.authorizeFileRead(recordId, context)` — the contract.
1216
+ - `sys_approval_action` declares `'approvals'`; `ApprovalService.authorizeFileRead`
1217
+ reuses the _same_ gate `listActions` applies (visibility of the parent
1218
+ request) rather than inventing a second, looser rule for the bytes.
1219
+
1220
+ **Fails closed**: a declared delegate that is missing or does not implement the
1221
+ method denies, rather than silently reverting to the raw read it was declared to
1222
+ replace. Objects without the declaration are unchanged.
1223
+
1224
+ Verified in the browser against app-showcase, both sides of the gate: the
1225
+ approver now downloads the real PDF (200), and an anonymous request is still
1226
+ refused (401) — the anonymous capability URL the original change closed stays
1227
+ closed. A decision attachment ends up exactly as readable as the decision it
1228
+ hangs off: never more, and no longer less.
1229
+
1230
+ - db48ad5: fix(security,approvals,metadata-core): restore batch routes on the eight objects the #3391 P1 companion fix missed (#3026)
1231
+
1232
+ The #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`: a batch
1233
+ request is admitted only when the object grants the `bulk` **primitive** and the
1234
+ batched child operation is itself allowed. Before that, the `*Many` routes
1235
+ checked only the child verb, so a boilerplate CRUD-five whitelist
1236
+ (`['get','list','create','update','delete']`) batched fine.
1237
+
1238
+ The companion fix — adding the `bulk` primitive wherever an explicit whitelist
1239
+ survived — was applied only inside `platform-objects`. Eight objects carrying
1240
+ the same boilerplate live in other packages and kept the gap, so `/batch`,
1241
+ `createMany`, `updateMany` and `deleteMany` answered `405
1242
+ OBJECT_API_METHOD_NOT_ALLOWED` on objects whose single-record create/update/
1243
+ delete were wide open. `data-objectstack` rethrows that 405 without falling back
1244
+ to per-row writes, which surfaced as a hard error on multi-select delete in the
1245
+ Setup grids.
1246
+
1247
+ Objects reclaimed (whitelist now `['get','list','create','update','delete','bulk']`):
1248
+ `sys_capability`, `sys_permission_set`, `sys_position`,
1249
+ `sys_position_permission_set`, `sys_user_permission_set`, `sys_user_position`
1250
+ (plugin-security); `sys_approval_delegation` (plugin-approvals);
1251
+ `sys_view_definition` (metadata-core).
1252
+
1253
+ No new authority is granted: `bulk` only permits batching verbs each object
1254
+ already exposes one record at a time, and every batched row still passes the
1255
+ same row- and field-level permission checks. The whitelists stay explicit rather
1256
+ than being deleted — seven of the eight are `managedBy`, and
1257
+ `reconcileManagedApiMethods` (ADR-0103 D3) early-returns on a non-array
1258
+ `apiMethods`, so dropping the line would silently disable the managed-write
1259
+ backstop.
1260
+
1261
+ - 83c161f: feat(automation)!: a flow run with no trigger user may no longer touch data (#3760)
1262
+
1263
+ An effective `runAs:'user'` run that resolves **no trigger user** used to execute
1264
+ its data nodes **UNSCOPED** — it presented no principal, and the data security
1265
+ middleware skips when there is no principal, so the run read and wrote every row.
1266
+ `runAs:'user'` is an access-_narrowing_ declaration; failing to resolve it must
1267
+ never resolve to a grant (ADR-0049). It now **refuses** the operation
1268
+ (`UnscopedRunDataAccessError`), naming `runAs:'system'` as the fix.
1269
+
1270
+ **This was never really about schedules.** The docs, the spec, the runtime
1271
+ warning and the lint all described a schedule-shaped problem, and the lint only
1272
+ ever matched that shape. But the runtime predicate is "no user", and the
1273
+ commonest way to have no user is a **record-change flow fired by a write that
1274
+ carried none**: `isSystem` does _not_ suppress trigger dispatch — only
1275
+ `skipTriggers` does, and exactly three first-party paths set it — so every
1276
+ plugin/service system write, the approvals status mirror, and a `runAs:'system'`
1277
+ flow's own data node dispatched record-change flows with `userId: undefined`.
1278
+ Ordinary users reach those writes routinely (submitting for approval mirrors a
1279
+ status onto the target record), so the fail-open was reachable by unprivileged
1280
+ input and was the common case, not the rare one.
1281
+
1282
+ Deliberately **not** implemented as "inherit the triggering write's posture and
1283
+ run as `isSystem`". That reads like a relabel but is a privilege escalation: the
1284
+ security middleware's `isSystem` short-circuit fires _before_ its
1285
+ package-managed-row, system-row, audience-anchor and delegated-admin gates, all
1286
+ of which a principal-less context still has to clear. Such a run cannot write
1287
+ `sys_user_position` today; as `isSystem` it could. "Unscoped" was never
1288
+ equivalent to "system".
1289
+
1290
+ **Breaking — how to migrate.** A flow that reacts to system writes and needs to
1291
+ act beyond one user's grants declares `runAs: 'system'`, making the elevation
1292
+ explicit and audit-attributable. Otherwise ensure the trigger supplies a user.
1293
+ Flows that touch no data are unaffected (`runAs` is moot), and the failure is
1294
+ isolated: the trigger already swallows flow errors, so the originating write
1295
+ still succeeds. The engine warns at run _setup_, before any node executes.
1296
+
1297
+ **#3712's user-less provenance path is subsumed, not broken.** That fix let a
1298
+ run with no trigger user write its own approval-locked record by carrying a
1299
+ provenance-only ObjectQL context (the run id, nothing else). Such a run can no
1300
+ longer perform a data operation at all — presenting no principal is exactly what
1301
+ made the write unscoped — so it is refused before the lock is consulted. The
1302
+ capability survives via the explicit route: a schedule that must write records
1303
+ declares `runAs:'system'`, which the lock hook exempts on its own `isSystem`
1304
+ branch. The `flowRunId` exemption itself stays live and load-bearing for what
1305
+ #3703 built it for — a `runAs:'user'` run that _does_ have a user — where the
1306
+ exemption is still provenance rather than privilege.
1307
+
1308
+ Also in this change:
1309
+
1310
+ - **`flow-schedule-runas-unscoped` → `flow-runas-unscoped`, and it now fails the
1311
+ build.** It read as a gate and behaved as a comment — `os compile` documented
1312
+ that the flow lint "NEVER fails the build" — which is close to no net at all
1313
+ for the audience it protects, very often an AI generating flows in bulk. It now
1314
+ also covers the other provably user-less triggers (`time_relative`, `api`), per
1315
+ ADR-0073 D5. It still cannot cover `record_change`, which is undecidable at
1316
+ authoring time — that is exactly why the runtime refusal exists.
1317
+ - **Three seed writes stopped firing automation.** The seed loader's pass-2
1318
+ deferred-reference back-fill and both of `AppPlugin`'s basic-insert fallbacks
1319
+ inlined a bare `{ isSystem: true }` instead of the shared seed options, so they
1320
+ seeded with record-change automation live — the self-trigger vector
1321
+ `skipTriggers` exists to prevent, on the writes that skipped it.
1322
+ - **ADR-0073 amended.** Its severity rationale ("an unprivileged user cannot
1323
+ trigger a schedule, so there is no untrusted-input path") is falsified, and its
1324
+ rejection of fail-closed ("breaks legitimate scheduled CRUD — 2/3 example flows
1325
+ relied on the default") expired when those flows were fixed to declare
1326
+ `runAs:'system'`. Refusal is an interim posture, forward-compatible with the
1327
+ ADR's `automation` principal: when that lands, the refusal point becomes the
1328
+ place that resolves it.
1329
+
1330
+ - Updated dependencies [50616d9]
1331
+ - Updated dependencies [08b5a3d]
1332
+ - Updated dependencies [d99aeb3]
1333
+ - Updated dependencies [4727eb8]
1334
+ - Updated dependencies [f63cd09]
1335
+ - Updated dependencies [fa3d0cf]
1336
+ - Updated dependencies [af5a224]
1337
+ - Updated dependencies [71f76e1]
1338
+ - Updated dependencies [37b1346]
1339
+ - Updated dependencies [99736a0]
1340
+ - Updated dependencies [fe67e34]
1341
+ - Updated dependencies [fdb4f50]
1342
+ - Updated dependencies [1bd5652]
1343
+ - Updated dependencies [14252d3]
1344
+ - Updated dependencies [7fb436c]
1345
+ - Updated dependencies [879ea13]
1346
+ - Updated dependencies [201b31f]
1347
+ - Updated dependencies [e2616e0]
1348
+ - Updated dependencies [6fdc5c6]
1349
+ - Updated dependencies [8b9d71e]
1350
+ - Updated dependencies [33f5e23]
1351
+ - Updated dependencies [259af21]
1352
+ - Updated dependencies [587fc91]
1353
+ - Updated dependencies [1986594]
1354
+ - Updated dependencies [ad4af62]
1355
+ - Updated dependencies [d44dbfa]
1356
+ - Updated dependencies [474fe39]
1357
+ - Updated dependencies [0bc685a]
1358
+ - Updated dependencies [b949059]
1359
+ - Updated dependencies [be1c52c]
1360
+ - Updated dependencies [c5ff96d]
1361
+ - Updated dependencies [84e7be9]
1362
+ - Updated dependencies [a6c3f38]
1363
+ - Updated dependencies [debc23a]
1364
+ - Updated dependencies [0f8ad09]
1365
+ - Updated dependencies [8f9689f]
1366
+ - Updated dependencies [57a3bb3]
1367
+ - Updated dependencies [5f9a987]
1368
+ - Updated dependencies [9f060e5]
1369
+ - Updated dependencies [bc17d39]
1370
+ - Updated dependencies [db02d47]
1371
+ - Updated dependencies [0bfdf46]
1372
+ - Updated dependencies [376a061]
1373
+ - Updated dependencies [7c7e246]
1374
+ - Updated dependencies [f35cdc5]
1375
+ - Updated dependencies [9ea2bc5]
1376
+ - Updated dependencies [c2d9098]
1377
+ - Updated dependencies [a227ed7]
1378
+ - Updated dependencies [9613396]
1379
+ - Updated dependencies [e47b342]
1380
+ - Updated dependencies [4ed7ed4]
1381
+ - Updated dependencies [2fa4ca1]
1382
+ - Updated dependencies [f5a2320]
1383
+ - Updated dependencies [deb538f]
1384
+ - Updated dependencies [5b89711]
1385
+ - Updated dependencies [0c8a22f]
1386
+ - Updated dependencies [763931e]
1387
+ - Updated dependencies [de9af8a]
1388
+ - Updated dependencies [c4df271]
1389
+ - Updated dependencies [a41ba5c]
1390
+ - Updated dependencies [189854c]
1391
+ - Updated dependencies [0e3a226]
1392
+ - Updated dependencies [524151c]
1393
+ - Updated dependencies [1d4756e]
1394
+ - Updated dependencies [720c5ad]
1395
+ - Updated dependencies [a8d1e24]
1396
+ - Updated dependencies [d1cabaa]
1397
+ - Updated dependencies [41642b0]
1398
+ - Updated dependencies [4cca74c]
1399
+ - Updated dependencies [88ef03e]
1400
+ - Updated dependencies [9e2caf3]
1401
+ - Updated dependencies [81ce41a]
1402
+ - Updated dependencies [85e1e4e]
1403
+ - Updated dependencies [dac6a08]
1404
+ - Updated dependencies [394b7a1]
1405
+ - Updated dependencies [677b591]
1406
+ - Updated dependencies [d77d1b7]
1407
+ - Updated dependencies [5b79a34]
1408
+ - Updated dependencies [c757854]
1409
+ - Updated dependencies [0045682]
1410
+ - Updated dependencies [2a5f04a]
1411
+ - Updated dependencies [4f740b0]
1412
+ - Updated dependencies [67452d1]
1413
+ - Updated dependencies [4921a95]
1414
+ - Updated dependencies [0fc6219]
1415
+ - Updated dependencies [605e190]
1416
+ - Updated dependencies [c6c59f1]
1417
+ - Updated dependencies [b0e78a8]
1418
+ - Updated dependencies [f31cc8d]
1419
+ - Updated dependencies [f343dc4]
1420
+ - Updated dependencies [8269e32]
1421
+ - Updated dependencies [74f7339]
1422
+ - Updated dependencies [a6c35a2]
1423
+ - Updated dependencies [c2f1002]
1424
+ - Updated dependencies [db48ad5]
1425
+ - Updated dependencies [f163028]
1426
+ - Updated dependencies [f07808c]
1427
+ - Updated dependencies [7ffc3d3]
1428
+ - Updated dependencies [88346ba]
1429
+ - Updated dependencies [4631592]
1430
+ - Updated dependencies [32ff033]
1431
+ - Updated dependencies [5ac93d4]
1432
+ - Updated dependencies [93f267f]
1433
+ - Updated dependencies [0024abf]
1434
+ - Updated dependencies [acbf364]
1435
+ - Updated dependencies [5487c20]
1436
+ - Updated dependencies [aa8b847]
1437
+ - Updated dependencies [7687f7b]
1438
+ - Updated dependencies [1659072]
1439
+ - Updated dependencies [abceb0d]
1440
+ - Updated dependencies [0c302a7]
1441
+ - Updated dependencies [6633337]
1442
+ - Updated dependencies [f00d8d4]
1443
+ - Updated dependencies [503be86]
1444
+ - Updated dependencies [cde1975]
1445
+ - Updated dependencies [0bc685a]
1446
+ - Updated dependencies [c073b8c]
1447
+ - Updated dependencies [11949fc]
1448
+ - Updated dependencies [b098b0e]
1449
+ - Updated dependencies [4d00b13]
1450
+ - Updated dependencies [9aa5510]
1451
+ - Updated dependencies [57bab76]
1452
+ - Updated dependencies [b90086a]
1453
+ - Updated dependencies [b95577a]
1454
+ - Updated dependencies [83c161f]
1455
+ - Updated dependencies [d8c4957]
1456
+ - Updated dependencies [f24cb83]
1457
+ - Updated dependencies [5dbbb92]
1458
+ - Updated dependencies [69f1dfd]
1459
+ - @objectstack/spec@17.0.0-rc.0
1460
+ - @objectstack/platform-objects@17.0.0-rc.0
1461
+ - @objectstack/core@17.0.0-rc.0
1462
+ - @objectstack/formula@17.0.0-rc.0
1463
+ - @objectstack/metadata-core@17.0.0-rc.0
1464
+
3
1465
  ## 16.1.0
4
1466
 
5
1467
  ### Patch Changes