@objectstack/plugin-approvals 17.0.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,594 @@
1
1
  # @objectstack/plugin-approvals
2
2
 
3
+ ## 17.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b47ba2c: **BREAKING** (compile-time only): `ApprovalServiceOptions['logger']` now
8
+ declares a **non-optional** `warn`, so a durability report always has
9
+ somewhere to land (#9754, #10556). This is the thirteenth of the thirteen
10
+ mechanical repairs the card names — held out of #10691 to serialize against
11
+ PR #10547, which owned `approval-service.ts` while it was open; that fence
12
+ has since cleared.
13
+
14
+ `minor`, not `major`: during the launch window this stack ships breaking
15
+ changes as `minor` — every publishable package versions in lockstep, so a
16
+ `major` would promote the whole release. `patch` would be wrong in the other
17
+ direction, because this *can* break a consumer's build. This is the same
18
+ reasoning #10691 used for the twelve sibling repairs; no exemption for a
19
+ types-only break was found there either, and none applies here.
20
+
21
+ `error` stays optional — hosts legitimately inject reduced sinks, and
22
+ requiring `error` was measured and rejected as #9754 option C. What changes
23
+ is that its *absence* now has a declared, guaranteed destination. Call sites
24
+ keep the `logger?.warn?.(…)` spelling as the backstop for hosts the type
25
+ cannot reach, so **no runtime behaviour changes**: nothing that printed
26
+ before stops printing, and nothing silent starts printing.
27
+
28
+ ### Who has to change, and what to do
29
+
30
+ Only a caller that constructs `ApprovalService` (or an `ApprovalServiceOptions`
31
+ value) with a `logger` object that has **no `warn` method** — for example
32
+ `{ error }` alone. Add a `warn` member; there is no rename, no removal, and no
33
+ stored value or metadata key to rewrite. The only non-test construction site
34
+ in this repo (`ApprovalsServicePlugin.start`, in this same package) passes the
35
+ kernel `ctx.logger`, whose `warn` is already required, so the in-repo cost is
36
+ zero.
37
+
38
+ <!-- adr-0087: not-required (runtime-interface-only packages/plugins/plugin-approvals/src/approval-service.ts#ApprovalServiceOptions) the tightened type is a plain TypeScript logger interface -- no Zod projection, no metadata surface, and it is referenced by none -- so `objectstack migrate meta` has nothing to rewrite. Nothing is removed or renamed and no stored value moves; the only consumer action is adding a `warn` member at a construction site the compiler names. -->
39
+ - 13f533a: fix(approvals): screen the `manager` approver to the request's organization (#10153)
40
+
41
+ `expandApprovers` hands the directory organization to every graph-shaped
42
+ approver expansion — `department`, `position`, `org_membership_level`. The
43
+ `manager` branch did not: `lookupManager` read `sys_user.manager_id` under a
44
+ system context and took no organization argument at all. `sys_user` is a global
45
+ identity table with no `organization_id`, so nothing else on that path supplied
46
+ the tenancy fact either. A `manager_id` crossing an organization boundary
47
+ therefore routed the submission to an approver **in another organization** — an
48
+ out-of-tenant person granted approval authority over the record.
49
+
50
+ The same column has been screened on the hierarchy side since cloud#1195. This
51
+ brings the approvals consumer into line for the `manager` branch.
52
+
53
+ ## What the screen is
54
+
55
+ `lookupManager(userId, organizationId)` now resolves the manager and then asks
56
+ whether he is **provably outside** the request's organization:
57
+
58
+ | membership rows for the manager | result |
59
+ |---|---|
60
+ | some exist, none in the request's org | **screened out** — the slot falls through to the `manager:<value>` literal |
61
+ | one is in the request's org | resolves, unchanged |
62
+ | none exist at all | resolves, unchanged — the tenancy fact is absent, not negative |
63
+ | the `sys_member` read failed | resolves, unchanged |
64
+ | the request carries no organization | resolves, unchanged — and no read is performed |
65
+
66
+ The fail-open half is this file's ruled posture on addressing paths, stated
67
+ twice already: `filterApproversWhoCanRead` refuses to empty a live slate on an
68
+ infrastructure hiccup, and `expandPositionUsers` carries "a step routing to
69
+ nobody is worse than one routing to a lapsed holder". A drop is logged with the
70
+ manager's id, his organizations and the request's, so the fix ("repair the link"
71
+ / "grant the membership" / "retarget the step") is legible without a debugger.
72
+
73
+ ## ⚠️ This moves one input from accepted to refused
74
+
75
+ A node whose **sole** approver is a cross-org `manager` and which is authored
76
+ with the **non-default** `onEmptyApprovers: 'fail'` used to open successfully;
77
+ it now throws `NO_APPROVERS`. Nothing new is thrown — a screened-out manager
78
+ leaves only a `type:value` literal, which the pre-existing empty-slate test
79
+ already classifies as empty, and `'fail'` already throws on empty. Every
80
+ screened sibling has reached that same bucket since it was written.
81
+
82
+ **The default policy is unaffected**: `admin_rescue` still opens the request
83
+ (decidable by a privileged admin) and warns, and `auto_approve` still
84
+ auto-approves. Both directions and both policies are pinned in
85
+ `manager-approver-org-screen.test.ts`.
86
+
87
+ ## What this does NOT decide
88
+
89
+ - **#7497** (does approver routing imply record read visibility?) stays open.
90
+ The screen reads `sys_member`, which looks like the D2 read filter beside it,
91
+ and the code says at length why it is the *sibling* treatment instead: two of
92
+ the three org-scoped expansions already screen on `sys_member.organization_id`,
93
+ and `sys_user` offers no other tenancy fact. No reads are granted and no read
94
+ screen is applied to any type that lacked one.
95
+ - **`team`** is still unscreened — it is a sibling graph expansion that is not
96
+ org-scoped either, tracked as #10230, and it touches this same file.
97
+ - `APPROVER_ORG_SCOPED` is untouched. It answers ADR-0105 D9 *retargetability*
98
+ (may an author write `organization:` on this type?), not screening, and
99
+ `manager: false` remains correct.
100
+ - e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an
101
+ optional `error` now declare a **non-optional** `warn`, so a durability report
102
+ always has somewhere to land (#9754, #10556).
103
+
104
+ `minor`, not `major`: during the launch window this stack ships breaking changes
105
+ as `minor` — every publishable package versions in lockstep, so a `major` would
106
+ promote the whole release. `patch` would be wrong in the other direction, because
107
+ this *can* break a consumer's build.
108
+
109
+ `error` stays optional on every one of these types — hosts legitimately inject
110
+ reduced sinks, and requiring `error` was measured and rejected as #9754 option C.
111
+ What changes is that its *absence* now has a declared, guaranteed destination.
112
+ Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the
113
+ type cannot reach, so **no runtime behaviour changes**: nothing that printed
114
+ before stops printing, and nothing silent starts printing.
115
+
116
+ ### Who has to change, and what to do
117
+
118
+ Only a caller that hands one of these sinks an object with **no `warn` method** —
119
+ for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no
120
+ rename, no removal, and no stored value or metadata key to rewrite. Every
121
+ construction site inside this repo already supplied one, so the in-repo cost was
122
+ zero; the compile error is reserved for the callers that were silently discarding
123
+ these reports.
124
+
125
+ The affected types, by package:
126
+
127
+ - `@objectstack/cloud-connection` — the internal `PluginContext['logger']`
128
+ - `@objectstack/metadata-protocol` — `IndexMigrationLogger`
129
+ - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks`
130
+ - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger`
131
+ - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal
132
+ `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']`
133
+ - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions`
134
+ - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']`
135
+ - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`,
136
+ `rule-hooks` and `record-share-cascade`
137
+ - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions`
138
+ - `@objectstack/service-knowledge` — `KnowledgeLogger`
139
+
140
+ `AuthManagerOptions['logger']` is the one most likely to be reached from outside:
141
+ `AuthManager` is public surface, its `logger` option stays optional, and a logger
142
+ that *is* supplied must now carry `warn`. The only non-test construction site in
143
+ this repo passes the kernel `Logger`, whose `warn` is already required.
144
+
145
+ `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger
146
+ field to `{}`. The field is now honestly optional rather than holding an empty
147
+ object that declared it could report and discarded everything. Behaviour is
148
+ unchanged in both directions.
149
+
150
+ <!-- adr-0087: not-required (runtime-interface-only packages/plugins/plugin-auth/src/auth-manager.ts#AuthManagerOptions, packages/plugins/plugin-auth/src/reconcile-membership.ts#ReconcileMembershipDeps, packages/metadata-protocol/src/migrations/partial-index-probe.ts#IndexMigrationLogger, packages/plugins/plugin-audit/src/auth-event-audit.ts#AuthEventAuditLogger, packages/plugins/plugin-audit/src/read-audit.ts#ReadAuditLogger, packages/plugins/plugin-reports/src/report-service.ts#ReportServiceOptions, packages/services/service-knowledge/src/knowledge-service.ts#KnowledgeLogger) every tightened type is a plain TypeScript logger interface -- no Zod projection, no metadata surface, and none is referenced by one -- so `objectstack migrate meta` has nothing to rewrite. Nothing is removed or renamed and no stored value moves; the only consumer action is adding a `warn` member at a construction site the compiler names. -->
151
+
152
+ ### Patch Changes
153
+
154
+ - 07bd1ca: Apply the subject object's field-level read controls to the approval payload
155
+ snapshot at serve time (#10749), so an approver no longer receives fields the
156
+ app author declared they may not read.
157
+
158
+ `sys_approval_request.payload_json` stores the submitted record's raw row,
159
+ captured from the flow's `$record` variable — which the automation layer hands
160
+ over with the record's own FLS never applying. The column is a `textarea` on
161
+ `sys_approval_request`, so to every read door it is an opaque string: the
162
+ field-visibility machinery governs *columns of objects* and cannot see inside a
163
+ JSON column. Every field-level read control declared on the SUBJECT object —
164
+ `requiredPermissions` (ADR-0066 D3), a permission set marking a field
165
+ non-readable, a `maskingRule` — was therefore unenforceable on the approval
166
+ path, for every app.
167
+
168
+ Per the maintainer's ruling (2026-08-22, Option B) the full snapshot **stays at
169
+ rest**: the approval record remains audit evidence of what was actually
170
+ submitted, which write-time trimming would have given away. Redaction happens at
171
+ **serve** time, keyed on the reading caller, so the same row answers an admin
172
+ with the whole snapshot and a restricted approver with only the fields they may
173
+ read. The readable set is not recomputed — it comes from the security service's
174
+ `getReadableFields`, documented as the same field mask the read middleware
175
+ applies, so this seam cannot drift from data-plane FLS.
176
+
177
+ Two doors are covered, because `payload_json` has two independent readers and a
178
+ seam covering one manufactures the belief that the path is masked:
179
+
180
+ - the **service door** (`getRequest` / `listRequests`, behind
181
+ `GET /api/v1/approvals/requests[/:id]`), which serves the parsed `payload`.
182
+ Redaction runs BEFORE display enrichment, so `payload_display` and
183
+ `payload_labels` — both built by walking the snapshot's own keys — cannot ship
184
+ a restricted field's name, its authored label, or the title of the record it
185
+ points at;
186
+ - the **generic data door**: the object declares
187
+ `enable.apiMethods: ['get','list']`, so a plain `find`/`findOne` returns the
188
+ raw string without the service ever running. Covered by object-scoped engine
189
+ middleware, which reaches the whole family sharing that producer (REST data
190
+ routes, ObjectQL, CSV/XLSX export, MCP). Middleware rather than an `afterFind`
191
+ hook on purpose: a hook receives `buildSession`'s output, which carries no
192
+ `onBehalfOf`, so a hook-based seam would drop the ADR-0090 D10 delegator
193
+ intersection and answer a delegated read more permissively than the service.
194
+
195
+ **Behaviour change, argued rather than assumed.** A non-admin caller that was
196
+ reading restricted keys out of the snapshot now receives fewer keys, and that is
197
+ a real change for such a consumer. It is shipped as a fix rather than a breaking
198
+ change because those fields were never that caller's to read: the approval path
199
+ was a bypass of a declaration the platform enforces everywhere else, and the
200
+ served type is `payload?: unknown` — never a promised field set. This follows the
201
+ `__search` companion strip (#7642), which shipped the same way on the same
202
+ reasoning. Object-level access is deliberately untouched: an approver commonly
203
+ holds no read grant on the object under approval at all, and
204
+ `getReadableFields` answers a caller with no field-permission entries with the
205
+ full set, so every approval drawer shipping today keeps rendering.
206
+
207
+ `hidden: true` is deliberately NOT acted on. It is a UI contract ("Hidden from
208
+ default UI") which, in the spec's own words, "has never governed serialization"
209
+ — measurably: no read path in the repo strips a value on it. Enforcing it here
210
+ alone would make the approval path stricter than a direct read of the same row
211
+ (closing no leak, since the approver can simply read the record) while breaking
212
+ drawers that render a `hidden` business column. That is a `packages/spec`
213
+ semantics question and is left open.
214
+ - f0d7647: **Deliberate search-semantics change.** `ApprovalService.listRequests` /
215
+ `countRequests` no longer push a free-text predicate onto the payload snapshot
216
+ column for a caller whose view of that snapshot is masked (#11040).
217
+
218
+ Since #10749 the approval snapshot (`sys_approval_request.payload_json`, the
219
+ submitted record's row) is redacted **at serve time, per reader**: each row is
220
+ cut down to the fields that caller may read on that row's subject object. The
221
+ full row deliberately stays at rest, so the approval record remains audit
222
+ evidence of what was actually submitted.
223
+
224
+ The free-text filter, however, is evaluated by the driver against the stored
225
+ column — before anything is served, and therefore against the unmasked bytes. A
226
+ predicate over that column is a question about its contents whose answer is row
227
+ membership, so the field-level read controls #10749 enforces on the way out did
228
+ not hold on the way in. That is `declared ≠ enforced`, and the platform has
229
+ already settled the governing principle for it: under `maskingRule` (#8993) a
230
+ field a caller sees masked is non-filterable, refused loudly, because otherwise
231
+ equality probes reconstruct the hidden span. This extends that settled posture
232
+ to the snapshot column, which is reached through a different door.
233
+
234
+ **What changes.** For a caller whose view of the snapshot is masked, free-text
235
+ search matches on `process_name`, `object_name`, `record_id` and `submitter_id`
236
+ — the columns of `sys_approval_request` itself, which anyone who can see the row
237
+ reads whole — and no longer on snapshot contents. Such a caller can still find a
238
+ request by process, object, record id or submitter; they can no longer find one
239
+ by a value they may not read. Rows returned, their order and pagination are
240
+ otherwise untouched, and no query is refused: the change only ever removes one
241
+ disjunct, never denies.
242
+
243
+ **What does not change.** A caller the serve path hands the whole snapshot to
244
+ keeps today's behaviour exactly — same rows, same order. That includes every
245
+ deployment that has not wired a field-visibility authority (the seam is
246
+ late-bound, and absent it snapshots are served unredacted), and the case where a
247
+ wired authority declines to narrow. Consistency with the serve path is the rule
248
+ here rather than blanket fail-closed: where serve hands over the whole snapshot,
249
+ keeping the predicate discloses nothing serve does not already disclose.
250
+
251
+ The masked/unmasked verdict is read from **the same authority and the same
252
+ per-caller call the serve path uses**, asked as the caller. It is deliberately
253
+ not a second, independently derived notion of "redacted" — two derivations drift,
254
+ and the drift between a serve rule and a filter rule is exactly what this fixes.
255
+
256
+ Because redaction is decided per row while a filter is built before any row
257
+ exists, the predicate-time scope matters: with an `object` filter the subject
258
+ object is known and the seam is asked about it directly; without one the query
259
+ spans every object, nothing sound can be asked, and the disjunct is dropped.
260
+
261
+ Held by `approval-free-text-scope.test.ts`.
262
+ - 6d5c4fa: Release these plugins' resources from `destroy()`, the teardown hook the kernel
263
+ actually calls (#10371). `Plugin` declares `init()`, `start?(ctx)` and
264
+ `destroy?()` — and no `stop()` — so `ObjectKernel.performShutdown()` and
265
+ `LiteKernel.destroy()`, which walk the plugins in reverse calling
266
+ `plugin.destroy()`, walked straight past every plugin whose teardown was spelled
267
+ `stop()`. `await kernel.shutdown()` resolved with the reports dispatcher still
268
+ armed, the REST/OpenAPI/Slack connectors still registered on the automation
269
+ engine, the approvals SLA escalation job still scheduled, and the knowledge
270
+ event-sync subscription still open.
271
+
272
+ Each teardown body now lives in `destroy()`. `stop()` is retained as a
273
+ delegating alias with its parameter made optional, so an embedder that learned
274
+ to call it directly — precisely because the kernel never did — keeps working
275
+ unchanged. No export is removed and the `Plugin` interface is untouched.
276
+
277
+ Same defect as #9371 in `@objectstack/service-messaging`, which surfaced as
278
+ fully green test runs exiting 1 on `EnvironmentTeardownError` and being evicted
279
+ from the merge queue.
280
+ - aa765b9: **Who loses access:** members of a team belonging to a *different* organization
281
+ than the record being approved. Concretely — a request raised in `org_a` routed
282
+ to a `team` approver whose `sys_team.organization_id` is `org_b` used to place
283
+ every `sys_team_member` of that team into `pending_approvers`, giving them the
284
+ approve/reject buttons on a record they are not a tenant of. They no longer
285
+ enter the slate, and the step falls back to the dead `team:<id>` literal with
286
+ the existing `#3807` "expanded to nobody" warning — the same shape a cross-org
287
+ `position` approver has always produced (#10230).
288
+
289
+ `team` was the last approver expansion that resolved people without asking
290
+ which organization was asking; `department`, `position`, `org_membership_level`
291
+ and (since #10153) `manager` all do. The screen reads the team's own
292
+ `organization_id`, so it costs one row and a team that fails it never fans out.
293
+
294
+ **Who does not lose access**, deliberately: a team stamped with the request's
295
+ own organization; a team stamped with **no** organization (`organization_id:
296
+ null` on a platform object means "owned by no organization" — what a seed
297
+ writes, since a seed cannot know the id the runtime mints at boot); a team id
298
+ with no `sys_team` row at all; and any request that carries no organization —
299
+ all four leave routing exactly as it was, because the tenancy fact is absent
300
+ rather than negative.
301
+
302
+ ⚠️ One externally observable accept→reject change beyond the routing itself:
303
+ under the non-default `onEmptyApprovers: 'fail'` policy, a node whose *sole*
304
+ approver was a cross-org team used to open a request and now throws
305
+ `NO_APPROVERS`. Under the default (`admin_rescue`) the node still opens.
306
+ - c5d0c2f: Screen expanded `team` approver members to the request's organization (#10547).
307
+
308
+ #10230 made a `team` approver prove the TEAM's tenancy, and deferred the
309
+ members on purpose. `sys_team_member` carries `team_id` and `user_id` and no
310
+ organization column, so a team that passed that screen still routed every user
311
+ id it listed — including a user whose only `sys_member` row is in another
312
+ organization. Measured on a fixture, not read off the schema: an `org_a`
313
+ request against an `org_a` team returned `["u_outsider","u_insider"]` with zero
314
+ `sys_member` reads.
315
+
316
+ The expansion now screens the members with the same provably-outside posture
317
+ the neighbouring screens pin, in ONE `$in` read for the whole slate:
318
+
319
+ - membership rows exist for the user and none is the request's organization
320
+ (present and NEGATIVE) — dropped, with a warning naming the users, the team
321
+ and both organizations;
322
+ - no membership rows, an unreadable `sys_member`, a possibly-truncated read, or
323
+ a request carrying no organization (ABSENT) — routing is left exactly as it
324
+ was, and the no-organization case performs no read at all.
325
+
326
+ Holding membership elsewhere is not disqualifying; holding none here is.
327
+
328
+ ⚠️ Behaviour change, confined to one non-default policy: a node whose only
329
+ approver is a team staffed entirely by users provably outside the organization
330
+ now resolves to no one. Under the default `onEmptyApprovers: 'admin_rescue'` it
331
+ still opens, routed to the dead `team:<id>` literal as any unresolved slate is;
332
+ under `onEmptyApprovers: 'fail'` it now throws `NO_APPROVERS` where it
333
+ previously opened.
334
+
335
+ Residual condition on the security value: the screen can only act on tenancy
336
+ facts that exist. A deployment that stamps an organization on its approval
337
+ requests but does not materialize `sys_member` rows sees no change — by design,
338
+ since #3807 recorded what treating an absent fact as a negative one costs.
339
+ - Updated dependencies [8f04d9a]
340
+ - Updated dependencies [6936d07]
341
+ - Updated dependencies [59eb04d]
342
+ - Updated dependencies [9f05b7d]
343
+ - Updated dependencies [3b2af5e]
344
+ - Updated dependencies [7d2d112]
345
+ - Updated dependencies [5fa0d72]
346
+ - Updated dependencies [8cc8401]
347
+ - Updated dependencies [02b3b07]
348
+ - Updated dependencies [46d34ab]
349
+ - Updated dependencies [914c413]
350
+ - Updated dependencies [55809a0]
351
+ - Updated dependencies [ee2ff45]
352
+ - Updated dependencies [47cd3ec]
353
+ - Updated dependencies [52db1d1]
354
+ - Updated dependencies [5649efb]
355
+ - Updated dependencies [9d7d2de]
356
+ - Updated dependencies [c815c50]
357
+ - Updated dependencies [795ea05]
358
+ - Updated dependencies [2306a76]
359
+ - Updated dependencies [e5ea701]
360
+ - Updated dependencies [26f3588]
361
+ - Updated dependencies [a40dcc1]
362
+ - Updated dependencies [def0d3e]
363
+ - Updated dependencies [8d0bb79]
364
+ - Updated dependencies [5acb58d]
365
+ - Updated dependencies [2e3cf95]
366
+ - Updated dependencies [4c93387]
367
+ - Updated dependencies [504c8d5]
368
+ - Updated dependencies [a037f7c]
369
+ - Updated dependencies [3ee8ddf]
370
+ - Updated dependencies [16cef97]
371
+ - Updated dependencies [a79bd35]
372
+ - Updated dependencies [6ceaa4b]
373
+ - Updated dependencies [15ea214]
374
+ - Updated dependencies [de19489]
375
+ - Updated dependencies [c684d00]
376
+ - Updated dependencies [923c424]
377
+ - Updated dependencies [0ab81d1]
378
+ - Updated dependencies [1ec36b7]
379
+ - Updated dependencies [5f2e54c]
380
+ - Updated dependencies [189373b]
381
+ - Updated dependencies [35ad101]
382
+ - Updated dependencies [ceb33a9]
383
+ - Updated dependencies [dccbcec]
384
+ - Updated dependencies [05bc692]
385
+ - Updated dependencies [73d9795]
386
+ - Updated dependencies [8012960]
387
+ - Updated dependencies [266654d]
388
+ - Updated dependencies [f34f56b]
389
+ - Updated dependencies [f399618]
390
+ - Updated dependencies [75e9301]
391
+ - Updated dependencies [f334d66]
392
+ - Updated dependencies [2810695]
393
+ - @objectstack/platform-objects@17.2.0
394
+ - @objectstack/spec@17.2.0
395
+ - @objectstack/core@17.2.0
396
+ - @objectstack/metadata-core@17.2.0
397
+ - @objectstack/types@17.2.0
398
+ - @objectstack/formula@17.2.0
399
+
400
+ ## 17.1.0
401
+
402
+ ### Minor Changes
403
+
404
+ - 08d6d3c: feat(approvals): read-only approval visibility for users who can read the target record, per object, default OFF (#8652)
405
+
406
+ A new `ApprovalsPluginOptions.recordReaderVisibleObjects` names the objects on
407
+ which **a user who can READ a business record may also see that record's
408
+ approval requests and full action history** — read-only. Omitted or empty (the
409
+ default) leaves visibility exactly as it is today, so an existing deployment
410
+ sees no behaviour change on upgrade. **This is not a no-op change**: on an
411
+ object you list, a population that could previously see nothing gains a real
412
+ read.
413
+
414
+ ```ts
415
+ new ApprovalsServicePlugin({ recordReaderVisibleObjects: ['exam_sheet'] })
416
+ ```
417
+
418
+ **Who gains visibility.** Until now the visible set was submitter ∪ current
419
+ approver ∪ historical actor, with a platform/tenant admin override as the only
420
+ bypass — so a ledger or supervisor role that holds full read on the record but
421
+ never appears in the approval itself received `200` with an empty list, and the
422
+ Console's approval tab never rendered. On an enabled object, that role now sees
423
+ the record's approvals.
424
+
425
+ **What becomes visible on an enabled object**, stated plainly because the switch
426
+ is an opt-in decision about confidentiality:
427
+
428
+ - the approval request row, including its `payload` snapshot of the record as it
429
+ stood at submission time;
430
+ - the full action history — each actor, their decision, the timestamp, **and the
431
+ action's comment text** (意见正文);
432
+ - decision attachments on those actions, which are gated on the same rule.
433
+
434
+ Enable it on objects whose approval commentary the record's readers are meant to
435
+ see; the comment text is often evaluative, and it is per object precisely so
436
+ that enabling it for a ledger object does not enable it for anything else.
437
+
438
+ **What does NOT change.**
439
+
440
+ - **Read-only.** No approval action is delivered through this tier. Approve,
441
+ reject, reassign, recall and comment keep authorizing exactly as before — on
442
+ the pending-approver slate, the submitter, or admin override — and a viewer
443
+ admitted by this tier gets `can_act: false`. Seeing a request confers nothing.
444
+ - **No new permission concept.** The tier is anchored on the existing
445
+ record-read permission: the service asks the engine to read the record **as
446
+ the caller**, so ordinary object CRUD and RLS decide. No new role, grant type
447
+ or policy, and no host-injected visibility hook — a security predicate the
448
+ platform can neither constrain nor audit was considered and rejected.
449
+ - **The inbox.** An untargeted list is unchanged. The rule is anchored on one
450
+ record, so it applies only where a record is named — a list filtered by
451
+ `object` + `recordId` (what a record page's approval tab sends), or a request
452
+ loaded by id. A work queue does not become a browse surface.
453
+ - **Tenant isolation, and everything else about the existing visible set.** The
454
+ tier only ever adds ids to the participant set; it can never return the "sees
455
+ everything" verdict and never relaxes an existing constraint.
456
+
457
+ ### Patch Changes
458
+
459
+ - 66beee0: Guard every authored record-scoped action predicate for the sparse action face, so a list row that did not project the gated column no longer silently drops the button.
460
+
461
+ An action's `visible` / `disabled` predicate binds whatever record the client already fetched — a record-detail read, or a list row carrying only the view's `$select` projection. That binding stays sparse by decision (it is the one record binding the platform does not make total), and CEL aborts the whole expression at key resolution when a key is absent. The abort is fail-closed, so the button is simply not offered — indistinguishable to the user from the gate having said no, and reported nowhere.
462
+
463
+ Every authored predicate on `sys_user`, `sys_invitation`, `sys_member`, `sys_oauth_application` and `sys_approval_request` now opens each `record.*` read with `has()`. The guard is the minimal measured form per predicate, not one blanket rewrite: a bare equality against a literal needs `has()` alone, because CEL compares heterogeneously and answers `false` on a projected-null column rather than faulting.
464
+
465
+ Two predicates change what a user sees, both on `sys_oauth_application`, whose `disabled` column is nullable upstream and therefore null on every application nobody has ever toggled:
466
+
467
+ - `disable_oauth_application` was `!record.disabled`, which faulted on a projected-null row (`!` needs a bool) — so the Disable button was missing from every never-toggled application in the list. It is now `has(record.disabled) && record.disabled != true` and is offered.
468
+ - `enable_oauth_application` was `record.disabled`, which answered `null` rather than a boolean and left the decision to the renderer. It is now `has(record.disabled) && record.disabled == true`.
469
+
470
+ `sys_approval_request`'s decision levers gate on the attached `record.viewer` block and traverse, so they are guarded at the leaf (`has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true`). Measured, that is the minimal safe form for a nested read: the canonical `has(x) && x != null` conjunction still faults when the block is present but the flag is absent or null, while a leaf `has()` subsumes the parent `!= null` half. Their intended fail-closed behaviour is unchanged — it is now a real `false` instead of an evaluation fault.
471
+ - 7ff5aa2: `sys_automation_run` resolves its organization from the DECLARED `AutomationContext.tenantId` and from no other spelling (cloud#1395).
472
+
473
+ The suspended-run store read `context.organizationId ?? context.tenantId`. `AutomationContext` declares `tenantId` and not `organizationId`, and no producer writes the latter — `RecordChangeTrigger.buildContext` maps the hook session's organization onto `tenantId`, and the runtime's automation domain sets `tenantId` directly. The dead limb was not inert: the one test covering `sys_automation_run.organization_id` fed the phantom key, so the column's only coverage exercised a path production cannot reach and said nothing about the live one. The limb is removed, the fixture speaks the declared contract, and a test now asserts the absence so restoring the alias goes red.
474
+
475
+ Both `sys_approval_request.organization_id` and `sys_automation_run.organization_id` now document the measured attribution defect this uncovered and the negative control that makes it a defect: on a walled single-database boot these two tables stored customer activity with no organization (27/27 and 31/31) while `sys_audit_log` (1669 rows) was correctly attributed on the same boot, because the audit writer resolves the organization from the record the row is ABOUT rather than from the acting context. The write-side repair is not in this change — which column a side-table row should follow is an open contract question, since the audit resolver is scope-pinned to audit stamping by the #8778 ruling. The current behaviour is pinned by test so the fix must promote the assertion rather than quietly satisfy it.
476
+ - Updated dependencies [56656aa]
477
+ - Updated dependencies [c9f5950]
478
+ - Updated dependencies [d6e80b2]
479
+ - Updated dependencies [07e630e]
480
+ - Updated dependencies [66beee0]
481
+ - Updated dependencies [2f65b1b]
482
+ - Updated dependencies [720ee95]
483
+ - Updated dependencies [f287435]
484
+ - Updated dependencies [2782805]
485
+ - Updated dependencies [e43d63a]
486
+ - Updated dependencies [9aa8890]
487
+ - Updated dependencies [7c9c1dd]
488
+ - Updated dependencies [03520eb]
489
+ - Updated dependencies [75b7c24]
490
+ - Updated dependencies [d5552ca]
491
+ - Updated dependencies [d9813a9]
492
+ - Updated dependencies [8640fb2]
493
+ - Updated dependencies [2420641]
494
+ - Updated dependencies [2ad91c3]
495
+ - Updated dependencies [f57fb38]
496
+ - Updated dependencies [00777a0]
497
+ - Updated dependencies [d491625]
498
+ - Updated dependencies [2d0af57]
499
+ - Updated dependencies [420804d]
500
+ - Updated dependencies [716ac9b]
501
+ - Updated dependencies [a38408a]
502
+ - Updated dependencies [62b1427]
503
+ - Updated dependencies [7ea1372]
504
+ - Updated dependencies [23abe27]
505
+ - Updated dependencies [985a9cd]
506
+ - Updated dependencies [5f5e234]
507
+ - Updated dependencies [a8189ae]
508
+ - Updated dependencies [26e70fb]
509
+ - Updated dependencies [27a567d]
510
+ - Updated dependencies [42b05af]
511
+ - Updated dependencies [2b292ce]
512
+ - Updated dependencies [abcf853]
513
+ - Updated dependencies [8b9eba5]
514
+ - Updated dependencies [d575779]
515
+ - Updated dependencies [94f7ef8]
516
+ - Updated dependencies [c5ac5e4]
517
+ - Updated dependencies [a777944]
518
+ - Updated dependencies [dd88e1c]
519
+ - Updated dependencies [856527c]
520
+ - Updated dependencies [870f710]
521
+ - Updated dependencies [79c46da]
522
+ - Updated dependencies [7ff3975]
523
+ - Updated dependencies [29d055b]
524
+ - Updated dependencies [65589d6]
525
+ - Updated dependencies [2c86fe3]
526
+ - Updated dependencies [e196c6a]
527
+ - Updated dependencies [24173e9]
528
+ - Updated dependencies [4ab7523]
529
+ - Updated dependencies [19539b4]
530
+ - Updated dependencies [f8eb736]
531
+ - Updated dependencies [11b779e]
532
+ - Updated dependencies [739fe5b]
533
+ - Updated dependencies [4bfe1a5]
534
+ - Updated dependencies [2065e31]
535
+ - Updated dependencies [b69d0f5]
536
+ - Updated dependencies [4d47afe]
537
+ - Updated dependencies [e4e5c6e]
538
+ - Updated dependencies [9a56784]
539
+ - Updated dependencies [d00d2f6]
540
+ - Updated dependencies [df0c12d]
541
+ - Updated dependencies [d31785f]
542
+ - Updated dependencies [c308a4f]
543
+ - Updated dependencies [e2899f6]
544
+ - Updated dependencies [b6c7690]
545
+ - Updated dependencies [3851f87]
546
+ - Updated dependencies [845e164]
547
+ - Updated dependencies [2a29caa]
548
+ - Updated dependencies [09a6eee]
549
+ - Updated dependencies [1a7f907]
550
+ - Updated dependencies [cd455c8]
551
+ - Updated dependencies [e1bb0ca]
552
+ - Updated dependencies [30d3752]
553
+ - Updated dependencies [c80e7ae]
554
+ - Updated dependencies [09a9a8a]
555
+ - Updated dependencies [07026cf]
556
+ - Updated dependencies [5d4f3d5]
557
+ - Updated dependencies [4d80e8b]
558
+ - Updated dependencies [30b1c63]
559
+ - Updated dependencies [7fc01db]
560
+ - Updated dependencies [079b457]
561
+ - Updated dependencies [e43b211]
562
+ - Updated dependencies [890b38f]
563
+ - Updated dependencies [8bee54b]
564
+ - Updated dependencies [04f8fdb]
565
+ - Updated dependencies [7a537ce]
566
+ - Updated dependencies [593c4bf]
567
+ - Updated dependencies [6158146]
568
+ - Updated dependencies [84cb121]
569
+ - Updated dependencies [ca19ee8]
570
+ - Updated dependencies [a675b4d]
571
+ - Updated dependencies [b887013]
572
+ - Updated dependencies [ff08691]
573
+ - Updated dependencies [60e0f90]
574
+ - Updated dependencies [90c5285]
575
+ - Updated dependencies [402c125]
576
+ - Updated dependencies [7901b2d]
577
+ - Updated dependencies [56bca91]
578
+ - Updated dependencies [b3f9831]
579
+ - Updated dependencies [79394d7]
580
+ - Updated dependencies [730fd9a]
581
+ - Updated dependencies [44bc51d]
582
+ - Updated dependencies [bbbfcfc]
583
+ - Updated dependencies [73cfddf]
584
+ - Updated dependencies [d634e66]
585
+ - @objectstack/spec@17.1.0
586
+ - @objectstack/platform-objects@17.1.0
587
+ - @objectstack/types@17.1.0
588
+ - @objectstack/core@17.1.0
589
+ - @objectstack/metadata-core@17.1.0
590
+ - @objectstack/formula@17.1.0
591
+
3
592
  ## 17.0.0
4
593
 
5
594
  ### Minor Changes