@objectstack/plugin-approvals 17.1.0 → 17.3.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,1293 @@
1
1
  # @objectstack/plugin-approvals
2
2
 
3
+ ## 17.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - dda969c: fix(approvals): a deleted record's pending approvals auto-cancel instead of stranding in the inbox (#13568)
8
+
9
+ Deleting a record left every `pending` approval it had opened sitting in the
10
+ approvers' inbox — counted in the pending total, openable, and pointing at a
11
+ `record_id` that resolves to nothing. Nothing about it was module-specific:
12
+ an approval node that declares `lockRecord` blocks the EDIT, so "delete and
13
+ recreate" is the only route left to an author who needs to fix a submitted
14
+ record, and every such delete added another orphan. Maintainer ruling
15
+ 2026-08-31 (`总监席第 5 场决裁批 #5`, verbatim 「同意」): pending requests
16
+ auto-cancel on record delete — status `cancelled` plus a machine-readable
17
+ reason, rows KEPT for audit, out of the pending count and the inbox's default
18
+ view.
19
+
20
+ **Graded `minor`, and deliberately not `patch`.** The repair itself is a
21
+ defect fix, but it lands by WIDENING two published vocabularies and adding a
22
+ declared column, and this repo's convention grades a shipped service's
23
+ accept-set/behaviour move as `minor`. **No `BREAKING` banner**: nothing is
24
+ narrowed and no metadata that used to be accepted is now refused — the one
25
+ consequence a consumer can feel is that `ApprovalStatus` and
26
+ `ApprovalActionKind` each gained a member, so an exhaustive `switch` with no
27
+ default, or a `satisfies Record<ApprovalStatus, …>` map outside this repo,
28
+ now has a case to add. That is the same shape `returned` had when ADR-0044
29
+ landed it.
30
+
31
+ **Spec (`@objectstack/spec/contracts/approval-service`)**
32
+
33
+ - `APPROVAL_STATUSES` gains `cancelled` (+ its `APPROVAL_STATUS_LABELS`
34
+ entry). Its own terminal state rather than a re-use of `recalled`: a recall
35
+ is an ACT by the submitter, and filing a platform-initiated void as one
36
+ attributes a withdrawal to a person who never performed it.
37
+ - New `APPROVAL_CANCEL_REASONS` / `ApprovalCancelReason` /
38
+ `APPROVAL_CANCEL_REASON_LABELS`, single entry `record_deleted`. A
39
+ VOCABULARY, not free text, because the reason has a non-human consumer (the
40
+ inbox and the tombstone presentation branch on it) — and a CLASS, per the
41
+ ruling's wording, so the next platform-initiated cancellation cause extends
42
+ this list instead of minting a second terminal status for itself.
43
+ - `APPROVAL_ACTION_KINDS` gains `cancel` — the only kind with no human actor,
44
+ by construction.
45
+ - `ApprovalRequestRow.cancel_reason` declared, optional-nullable.
46
+
47
+ **Plugin (`@objectstack/plugin-approvals`)**
48
+
49
+ - `sys_approval_request.cancel_reason`, a select derived from the contract
50
+ vocabulary and never re-typed (the #3786 rule the `status` column already
51
+ follows). On the row rather than on the audit entry, so a plain list view
52
+ can read WHY without joining the append-only action log.
53
+ - `bindRecordDeleteCancelHook` — a GLOBAL `afterDelete` registration beside
54
+ the existing global record-lock hook, so one platform-level linkage covers
55
+ every "approval + `lockRecord`" object at once. It needs no row-set
56
+ plumbing: the engine binds the deleted row's pre-image on the by-id path
57
+ and fans `afterDelete` out per matched row on a predicate delete, so a bulk
58
+ delete is covered by the same handler. The approvals tables are excluded at
59
+ registration, so they do not pay the delete-side pre-image read.
60
+ - `ApprovalService.cancelForDeletedRecord` writes the transition: one
61
+ append-only `sys_approval_action` row (`action: 'cancel'`, no actor),
62
+ `status: 'cancelled'` + `cancel_reason: 'record_deleted'` +
63
+ `completed_at`, and a `sys_approval_approver` index clear — that last one
64
+ is not optional garnish, it is what actually empties the inbox, because the
65
+ approver filter resolves through that index rather than through `status`.
66
+ - The `Completed` list view now includes `cancelled`, so a kept audit row is
67
+ visible in the one curated terminal view rather than only under `All`.
68
+ - ⛔ **No flow resume and no status mirror-back.** A cancellation is a status
69
+ write plus a reason, not a decision, so there is no branch to resume down.
70
+ The mirror-back is skipped by construction rather than by a swallowed
71
+ error: it is an `update_record` against the row that was just deleted — the
72
+ exact write this card's forensics caught failing elsewhere. The suspended
73
+ run the request gated is reported at `warn` with its id and otherwise left
74
+ alone; what becomes of it belongs to the automation service.
75
+ - ⛔ **The delete is never refused.** The "forbid delete while an approval is
76
+ pending" direction was vetoed in the same ruling — `lockRecord` already
77
+ blocks the edit, and blocking the delete too locks an author onto a record
78
+ they cannot fix. Nothing in the hook throws; a failure degrades to the
79
+ pre-existing state (the stale row) and is logged.
80
+ - Terminal rows are untouched. `approved` / `rejected` / `recalled` /
81
+ `returned` requests about the deleted record keep their recorded outcome —
82
+ history stays history, and rendering their now-dead record reference is a
83
+ separate console-side change.
84
+
85
+ zh-CN / ja-JP / es-ES bundles carry authored translations for the new leaves
86
+ (已作废 / 無効化済み / Anulada), not source fills.
87
+ - 277948f: feat(spec,plugin-approvals): `escalation.enabled` defaults to `true` and the SLA sweep finally reads it (#12278)
88
+
89
+ **BREAKING** semantic default flip on a published authorable key, shipped as
90
+ `minor` under the repo's launch-window convention for breaking changes.
91
+ Maintainer ruling 2026-08-27 (Option C), explicitly reversing the 2026-08-26
92
+ "spec stays as declared" ruling with fresh analysis.
93
+
94
+ `ApprovalEscalationSchema.enabled` declared `default(false)` while the
95
+ plugin-approvals escalation sweep never read the key: any escalation block
96
+ with a positive `timeoutHours` escalated, and with `action: 'auto_approve'`
97
+ that silently approved requests their author had declared off the clock —
98
+ the ADR-0049 declared-but-unenforced shape, failing open. Worse, the
99
+ approval-node executor parses node config through the schema before
100
+ snapshotting it onto the request row, so the old default **materialized**
101
+ `enabled: false` into storage for every author who omitted the key, making
102
+ "authored off" and "defaulted off" byte-identical at the sweep site.
103
+
104
+ One change, both halves:
105
+
106
+ - **spec**: `enabled` now defaults to `true` (stays `z.boolean()`; no
107
+ tri-state). The feature-level switch is whether an `escalation` block
108
+ exists at all; within a block carrying `timeoutHours`, escalation is on
109
+ unless explicitly turned off — which is what the runtime, its eleven
110
+ behaviour tests, and every teaching surface have always meant. Declared in
111
+ `DEFAULT_CHANGES_BY_MAJOR` (17) and registered as the
112
+ `approval-escalation-enabled-default-flip` semantic migration entry.
113
+ - **runtime**: `runEscalations` skips a request whose snapshot carries an
114
+ explicit `escalation.enabled === false` — the declared switch is enforced.
115
+ Request snapshots created **before** the flip cutoff
116
+ (`ESCALATION_ENABLED_FLIP_CUTOFF_MS`, 2026-08-28T00:00:00Z) ride a
117
+ read-side legacy window and keep escalating exactly as they do today: their
118
+ stored `false` is overwhelmingly the old schema default materialized onto an
119
+ author who never wrote the key, every such stored row is escalating today,
120
+ and the window retires itself as pending requests drain — zero tenant rows
121
+ rewritten.
122
+
123
+ Deployed metadata that omits `enabled` does not change behaviour (it
124
+ escalated before, it escalates after). What changes is that writing
125
+ `enabled: false` finally binds for newly opened requests — the declared
126
+ intent being honoured. To keep an SLA off, write `enabled: false`; to
127
+ escalate on timeout, an `escalation` block with `timeoutHours` is enough.
128
+
129
+ <!-- adr-0087: registered approval-escalation-enabled-default-flip -->
130
+ - 3690922: fix(plugin-approvals): the non-submitter recall refusal renders through the
131
+ Operation Message Catalog instead of a hardcoded English sentence (#11993, the
132
+ services-side half of the shape-A ruling)
133
+
134
+ A user who opened a record someone else had submitted for approval, clicked
135
+ Recall and was correctly refused read the reason in English regardless of their
136
+ own locale. `@objectstack/rest`'s `handleApprovalError` ships this service's
137
+ thrown reason as the 403 body's human-readable `error`, and Console splices it
138
+ under its own localized label — so an operator in a fully Chinese deployment
139
+ read a Chinese prefix glued onto an English sentence they could not act on
140
+ (`撤回审批失败: <English>`).
141
+
142
+ The refusal now renders through the shared Operation Message Catalog in
143
+ `@objectstack/spec/system` under the key `approval_recall_not_submitter` that
144
+ #12493 landed for it — the same mechanism `plugin-security`'s denial gates
145
+ already use, with the same resolution ladder (deployment override → the
146
+ caller's locale → `en` → the key) and the same guarantee that a misbehaving
147
+ i18n service cannot turn a 403 into a 500. All four platform locales (`en`,
148
+ `zh-CN`, `ja-JP`, `es-ES`) ship copy that names who *can* recall, rather than
149
+ dead-ending the reader.
150
+
151
+ `ApprovalServiceOptions` gains an optional `messageTranslator` — a lazily
152
+ resolved, `II18nService.t`-compatible lookup, wired by `ApprovalsServicePlugin`
153
+ the same way `tenancyPosture` and the field-visibility source are, because the
154
+ i18n service is contributed by another plugin and may start later. It is what
155
+ makes the override address the catalog documents,
156
+ `errors.approval_recall_not_submitter`, actually take effect for this emitter;
157
+ a stack without an i18n service still renders the built-in catalog in the
158
+ caller's locale.
159
+
160
+ **Not changed: who may recall an approval.** The gate is byte-identical — the
161
+ submitter, or a privileged admin releasing a stuck record (#3424). Only the
162
+ sentence the refusal carries is different, and the `FORBIDDEN:` code prefix
163
+ that the REST layer maps to 403 is untouched.
164
+
165
+ The button-visibility half of #11993 — a non-submitter seeing a live recall
166
+ button at all — is not addressed here; see the issue for the measurement.
167
+ - 599515d: Four more system objects declare their polymorphic pointer pair (#11386, ADR-0052 §5, adopting the carrier #11339 landed): `sys_audit_log.record_id`, `sys_approval_request.record_id`, `sys_record_share.record_id` and `sys_share_link.record_id` now carry `referenceVia: 'object_name'`. A seed row addressing one of these by the target's natural key resolves against the object its sibling column names, per row — so a packaged app can ship audit history, pending approvals, record grants and share links that actually attach to the records they are about, and the queries that give each row its meaning (the `{object_name, record_id}` index, the pending-request lock, the sharing middleware's grant lookup, the share link's fail-closed record-existence gate) match on the target's real id.
168
+
169
+ The accept/reject contract changes with it on those four objects, deliberately and in the already-ruled direction: an unresolvable pointer on a DECLARED pair is a loud, counted failure instead of the old silent verbatim store. On a grant table that is the sharper win — a share whose `record_id` stayed a natural key enforced nothing while displaying as a grant, and was then deleted by the orphan sweep for describing a record that does not exist. Internal-id-shaped values still pass through verbatim, so a demo row about an already-deleted record (an `action: 'delete'` audit row) stays authorable. Undeclared text columns are untouched.
170
+
171
+ The fifth object surveyed, `sys_automation_run` (`trigger_object` / `trigger_record_id`), deliberately STAYS UNDECLARED. Its pair has the same shape but its rows are not content about a record: a `paused` row is a live continuation the engine rehydrates on boot, terminal rows are telemetry under a 30-day sweep, and the object has no natural key to address rows by. The verdict, its reasons, and what would have to change to flip it are recorded on the field itself and pinned by a test.
172
+
173
+ ### Patch Changes
174
+
175
+ - 1007134: fix(approvals): the #3424 override reaches a `pending` request only — override-recall of a `returned` request is now refused (#12775)
176
+
177
+ **Behaviour change, declared explicitly (maintainer ruling 2026-09-02) so that
178
+ one revert of this changeset's PR restores the previous behaviour.**
179
+
180
+ `ApprovalService.recall` admits two callers: the submitter, and a platform or
181
+ tenant admin releasing a stuck request (the #3424 privileged override). Recall
182
+ is also valid on the LATEST `returned` request of a run — the ADR-0044 revise
183
+ window, where the submitter abandons the revision instead of resubmitting.
184
+ Those two rules met above the state check: the override short-circuit carried
185
+ no status test of its own, so an override actor could recall a `returned`
186
+ request too. Nothing else on the platform said so — `isOverrideActor`'s doc
187
+ block names a PENDING request, `attachViewers` computes
188
+ `viewer.can_override` as `status === 'pending' && isOverrideActor(...)`, and
189
+ the `approval_recall` action's override arm reads that flag — so the reach was
190
+ API-only, never offered by any UI, and pinned by nothing.
191
+
192
+ What changes:
193
+
194
+ - **Override-recall of a `returned` request is refused.** The override
195
+ short-circuit in `recall` now applies only while the request is `pending`,
196
+ spelled exactly as the viewer flag is computed. On `returned` an override
197
+ actor is judged exactly as any other non-submitter and receives the existing
198
+ refusal: `403 FORBIDDEN` over REST, with the operation catalog's
199
+ `approval_recall_not_submitter` sentence. No new error code, no new envelope.
200
+ - **Only `pending` requests are override-recallable** — the same scope as the
201
+ other three override levers (approve / reject / reassign are pending-only at
202
+ their endpoints), and the same scope the viewer flag has always declared.
203
+
204
+ What does not change:
205
+
206
+ - The submitter's own recall of a `returned` request (the ADR-0044 revise
207
+ window) is untouched; so is the submitter's recall of a `pending` one.
208
+ - The override actor's recall of a `pending` request — the #3424 rescue of a
209
+ request routed to an unstaffed position — is untouched, lock release and all.
210
+ - The refusal's message, wire code and developer log line keep their shape; the
211
+ log line now also names the request status it refused on.
212
+
213
+ Why: the gate now agrees with the viewer flag and the documented contract at
214
+ one point instead of disagreeing with both. If a real operator workflow
215
+ depended on override-recalling a `returned` request, this is the change to
216
+ revert; the `returned` record lock is already released, so the stuck-record
217
+ rescue motive that justifies the override does not apply on that status.
218
+ - b579b03: fix(approvals): refuse loudly when a successful mutation's read-back is org-filtered out (#12769)
219
+
220
+ Ten ApprovalService result sites (decide/decideNode, recall, sendBack, resubmit,
221
+ reassign, remind, requestInfo, comment) read the row they just mutated back
222
+ through the caller's organization narrowing and asserted the result non-null
223
+ (`fresh!`). For an org-less request row — produced by construction on every
224
+ schedule / time-relative / api trigger run (#10131; pinned rather than repaired
225
+ by #9132) — an org-scoped caller's read-back matches nothing, so a call that
226
+ SUCCEEDED shipped a well-formed success envelope whose declared-non-null
227
+ `request` was `null` (HTTP 200 with `"request": null` through the REST
228
+ pass-through), and a client dereferencing `request.status` crashed.
229
+
230
+ The read-back now throws `READ_BACK_FAILED: …` instead: the write is recorded
231
+ and NOT rolled back; only the result echo is refused, loudly. Over REST the
232
+ error surfaces through each route's existing 500 arm (`APPROVAL_RECALL_FAILED`
233
+ and siblings — already-registered codes) with the `READ_BACK_FAILED:` message
234
+ in the body. No declared result type changed — the declared-non-null `request`
235
+ is now always true because a result that cannot be built is never returned. The
236
+ caller-org narrowing in `loadRequest` (tenancy wall) is deliberately untouched.
237
+ - 2a05851: fix(plugin-approvals): the `approval_recall` action shows for the #3424 admin override (#12716)
238
+
239
+ `ApprovalService.recall` has admitted two callers since #3424 — the submitter,
240
+ and a platform/tenant admin releasing a stuck request — and `isOverrideActor`'s
241
+ own doc block names recall as one of the four override levers in so many words.
242
+ The declared action that reaches that endpoint did not agree: `approval_recall`'s
243
+ `visible` predicate was submitter-only, while its three siblings
244
+ (`approval_approve` / `approval_reject` / `approval_reassign`) each OR in
245
+ `record.viewer.can_override`.
246
+
247
+ So recall was the one lever the override covers whose button never appeared. An
248
+ admin rescuing an approval routed to an unstaffed position could approve or
249
+ reject their way out — writing a decision nobody made — or reassign it, but
250
+ could not simply withdraw it. This is declared-vs-enforced drift in the less
251
+ usual direction: a capability the server grants that no UI entry exposed.
252
+
253
+ `approval_recall`'s `visible` now ORs in `record.viewer.can_override`, spelled
254
+ byte-identically to the three siblings.
255
+
256
+ Not a permission change: the service's authorisation set is untouched, and
257
+ `can_override` was already computed server-side for every viewer.
258
+
259
+ **Pending-only, and enforced rather than asserted.** The new arm carries no
260
+ status test of its own — neither do the siblings — because the flag is already
261
+ status-scoped where it is computed: `attachViewers` sets
262
+ `can_override: row.status === 'pending' && isOverrideActor(...)`, ANDed, so the
263
+ flag can never be true off `pending` and the arm is pending-only in effect
264
+ however CEL groups the expression. The submitter's own `returned` (revise
265
+ window) arm is unchanged. Pinned in both directions, with the flag's own scoping
266
+ pinned against the real service on a genuinely `returned` row.
267
+ - 9bc403e: Correct what `sys_approval_request.payload_json` is documented to be FOR — it is audit evidence served redacted per reader, not a notification source
268
+
269
+ The object's module docstring — which ships to consumers in the package's type
270
+ declarations — justified the snapshot column with: *"used by notifications so
271
+ they can render before the record is locked or changed."* That consumer does
272
+ not exist. Measured against every `this.notify(...)` call site in
273
+ `approval-service.ts`, all **12** of them, each passes a payload of
274
+ `{ title, message, actionUrl }` (two also carry `actions`), built from
275
+ `object_name` / `record_id` and the caller's own comment. **None** reads
276
+ `payload_json` or the parsed `payload`.
277
+
278
+ This is more than tidiness: that sentence was the only documented
279
+ justification for the column holding a *full* row, and it was cited as such
280
+ during the #10749 consumer inventory before anyone checked it. The docstring
281
+ now states the real reason — the snapshot is retained as **audit evidence of
282
+ what was actually submitted**, so the column stays whole at rest, and is served
283
+ **redacted per reader** by the subject object's field-level read controls via
284
+ `getReadableFields`, on the approvals-inbox door and the generic data door
285
+ alike (#11039).
286
+
287
+ The field's own `description` is deliberately unchanged: `Record snapshot at
288
+ submission time` is accurate, and it — unlike the JSDoc — is the string
289
+ extracted into the four generated i18n bundles, so no translation leaf moves
290
+ and no locale is left holding an English seed.
291
+
292
+ Also carried in the same pass, the residual documentation the #10749 closure
293
+ assigned to the next docs touch in this lane: `payload-redaction.ts` recorded
294
+ `hidden`-vs-serialization as an **open** `packages/spec` question, and it has
295
+ since been ruled (maintainer, 2026-08-24, applying the 2026-08-12 lineage).
296
+ That paragraph now states the ruling — **`hidden: true` stays UI-only;
297
+ `internal: true` is the serialization primitive** — so an author who needs a
298
+ field kept out of read results is pointed at `internal: true` (#7728,
299
+ ADR-0049) rather than at `hidden`, which never governed serialization.
300
+
301
+ Documentation only: no runtime behaviour, no schema field, and no public type
302
+ signature changes.
303
+ - 59c0891: fix(plugin-approvals): the stranded-request inspection now sees a run that FAILED mid-resume (#13909)
304
+
305
+ `ApprovalService.inspectStrandedRequests` was structurally blind to the shape
306
+ #13909 owns, and reported `0` for it — the one shape an operator most needs to
307
+ see.
308
+
309
+ **The mechanism.** `AutomationEngine.resumeInternal` consumes the suspension
310
+ *before* running the downstream nodes: `forgetSuspendedRun(run, 'resumed')`
311
+ precedes `traverseNext`. A downstream node that merely THREW therefore threw
312
+ with the pause already gone, the catch arm recorded the run `failed`, and
313
+ nothing can resume it again (`resume` answers `RUN_NOT_FOUND`, `cancelRun` is a
314
+ no-op). The decision is durable and the flow stopped half-way.
315
+
316
+ **Why the inspection could not see it.** Its second oracle was
317
+ `if (terminal) continue` — the existence of ANY run-history row ended the check,
318
+ on the reading "the run ran to a terminal state, it is not dangling". But the
319
+ terminal row here is written BY the failure that stranded the request, so the
320
+ evidence of the defect was read as evidence of health. `releaseDeadRunRequests`
321
+ cannot see it either: it scans `status: 'pending'`, and the decision is what
322
+ took the row out of `pending`.
323
+
324
+ **The widening, and its limits.** The second oracle now classifies the run
325
+ instead of merely detecting it. A `failed` run is reported; `completed`,
326
+ `cancelled` and `paused` are each still skipped, one named reason at a time, and
327
+ a status this code does not recognise is skipped too — the spec's
328
+ `ExecutionStatus` vocabulary is wider than the four statuses the engine writes,
329
+ and a future status must not become a silent false positive. `paused` in
330
+ particular stays skipped because "the suspension is gone but no terminal row is
331
+ written yet" is exactly what a resume IN FLIGHT looks like. The first oracle is
332
+ unchanged: a run the suspension store still holds is alive, and an unreadable
333
+ store is still counted `undetermined`, never condemned.
334
+
335
+ Reported rows now carry `runState: 'missing' | 'failed'` (new exported type
336
+ `StrandedRunState`), because the two shapes need different remedies: a `missing`
337
+ run has no history to read, a `failed` one has a step log and an error naming
338
+ the node that threw. The sweep's own warning splits its counts the same way.
339
+ `StrandedApprovalRequest` is an output-only reporting shape the service
340
+ produces; the added field is not constructed by any caller in this repo.
341
+
342
+ **Still read-only, and still not a census.** No status is changed and no run is
343
+ cancelled — the decision genuinely happened. This makes the condition *visible*
344
+ in a deployment; how many runs are already in it can only be answered against
345
+ that deployment's own tables. Nothing here changes the resume ordering, which
346
+ is #13909's own next slice.
347
+ - 4bb09e8: feat(service-automation): an operator can put back a suspension a failed resume consumed (#13909)
348
+
349
+ A run that was resumed and whose downstream node merely **threw** was
350
+ terminally unresumable, and nothing anywhere could move it. The engine consumes
351
+ the suspension *before* running downstream nodes, so such a node throws with
352
+ the pause already gone and the catch arm records the run `failed`: `resume`
353
+ then answers `RUN_NOT_FOUND`, `cancelRun` is a no-op, and none of the engine's
354
+ other public methods takes the run anywhere. A deployment could enter that
355
+ state and never leave it.
356
+
357
+ `AutomationEngine.restoreConsumedSuspension(runId, { requestedBy, reason })`
358
+ is the exit. It puts the consumed suspension back — verbatim, as it stood at
359
+ the pause — so the run is resumable again through an ordinary `resume`, with
360
+ the same authority gate, the same screen validation and the same idempotency
361
+ guard as any other.
362
+
363
+ - **Deliberate, never automatic.** Nothing calls it on its own: no retry, no
364
+ sweeper. An operator asks for one run, by id.
365
+ - **Safe to refuse, with the reason named.** A run that is still suspended, one
366
+ whose resume is *in flight*, one that completed, one that was cancelled, one
367
+ that never suspended, and an unknown id each get their own refusal — as does
368
+ an unreadable store, which is refused rather than guessed at.
369
+ - **Idempotent.** A suspension is keyed by run id, so however many operators
370
+ ask there is one resumable pause and no extra traversal — the verb re-arms
371
+ and stops. Two racing callers in one process get one restore and one refusal.
372
+ - **It leaves a trace.** The restore is logged with the run, flow, node, when
373
+ the suspension was consumed, who asked and why, and the run is recorded
374
+ `paused` again so the repair is not invisible. Across a restart the exit
375
+ still works: the consumed suspension rides the run's own terminal history row
376
+ (in `sys_automation_run` columns that already existed), and a run that is
377
+ restored and then finishes clears it.
378
+
379
+ ⚠️ A repair, not a prevention. The failed attempt's side effects are **not**
380
+ undone and the original resume signal is **not** replayed — the continuation
381
+ must be re-issued. Whether the pause should survive a downstream throw at all
382
+ is a separate, unruled decision (#13937); this change leaves the resume
383
+ ordering, `forgetSuspendedRun` and `traverseNext` exactly as they are, mints no
384
+ new run status, and works whichever way that is ruled — the runs already stuck
385
+ today are not released by changing what future resumes do.
386
+
387
+ `plugin-approvals` carries a comment correction only: its organization backfill
388
+ documented `context_json` as never written on terminal rows, which this change
389
+ makes false for that one class of row. No behaviour change there.
390
+ - 5a916c4: **Ops:** a one-off, idempotent backfill for the platform rows the pre-#10101 writers stranded with no organization — dry run first (#11308).
391
+
392
+ #10101 fixed the WRITERS: a `sys_approval_request` and a `sys_automation_run` are now stamped from the SUBJECT record's organization, with the acting context as the ruled fallback. It wrote nothing to existing rows, so the population produced before it persists — a **pending** org-less approval request LOCKS the record it is about while being invisible in every organization-scoped inbox, its own owner's included, and automation-run history stays unattributed. This is the repair for those rows, on the maintainer's 2026-08-23 ruling (direction 3).
393
+
394
+ `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` sweeps `sys_approval_request` (with its `sys_approval_action` / `sys_approval_approver` children, which move with their request) and `sys_automation_run`. It scans only rows whose organization column is unset, re-reads each row's subject at repair time — live record first, the write-time snapshot (`payload_json` / `context_json`'s `record`) second for a subject that has since been deleted — and stamps the platform row with the subject's own organization.
395
+
396
+ **Dry run first, and by default.** `planPlatformRowOrganizationBackfill(engine)` reads only and returns a per-object report naming every row it would touch; `runPlatformRowOrganizationBackfill(engine, { dryRun: false })` writes. Nothing runs at boot and nothing is scheduled: this is an operator-invoked module, run once against an affected install.
397
+
398
+ **Rows whose subject is equally org-less are counted and named, never written.** The acting-context fallback the writers apply is not available to a repair — the acting context is gone — and inventing one stays vetoed. Those ids are reported so the population is checkable and stays visible.
399
+
400
+ **`sys_api_key`'s divergence is preserved, not flattened.** Both the column read on a subject and the column written on a platform row are resolved from the registered schema through the shared `createRecordOrganizationResolver` (`@objectstack/metadata-core`), so a platform row ABOUT an API key is repaired from that object's stamp-only `active_organization_id` (limb 0, #8778) and the credential table itself is never written to.
401
+
402
+ **Idempotent, and asserted rather than claimed.** Every scan is `WHERE <organization column> IS NULL` and every write fills that column, so a repaired row cannot match again; the test suite runs the sweep twice and pins the second run at zero writes.
403
+
404
+ Publishes no runtime code: the module is not exported from the package index and not bundled into `dist` (`tsup` builds `src/index.ts`). It is graded rather than skipped because the release notes are where an operator of an affected install learns the repair exists, what it will and will not touch, and that the dry run comes first.
405
+ - 30928a6: fix(i18n): read the provenance companion at serving time, not only record it (#12642)
406
+
407
+ Maintainer ruling #12069 Option A (#11671) landed translation provenance as
408
+ **two** halves: `os i18n extract --source-hashes` RECORDS which source revision
409
+ a generated leaf is still a byte copy of, and `withSourceFallback` READS those
410
+ records at serving time and substitutes the current source for a leaf whose
411
+ source has moved underneath it. The recording half was then rolled out to every
412
+ bundle set. The reading half was not — measured on `main`: provenance
413
+ **recorded in 9 of 9** bundle sets and **read at serving time in 1**.
414
+
415
+ The other eight assembled their `TranslationBundle` straight from the raw
416
+ generated modules and never consulted the companion sitting beside them, so
417
+ they recorded the drift and went on serving the superseded draft. Nothing said
418
+ so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage`
419
+ counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale
420
+ rule needs a SECOND locale holding the same stale bytes before it can testify.
421
+ The measured case had one locale and no second witness.
422
+
423
+ All eight are wired here, in the shape `@objectstack/platform-objects`'s own
424
+ `metadata-translations/index.ts` uses — the committed
425
+ `<locale>.source-hashes.generated.ts` passed as the fourth argument, the third
426
+ left `undefined` because these sets have no hand-authored sections. Provenance
427
+ is now recorded in 9 of 9 sets and served in 9 of 9.
428
+
429
+ `@objectstack/plugin-webhooks` was the last of them and is the only one whose
430
+ manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`,
431
+ which that package did not declare. It was **already in that package's install
432
+ closure** through `@objectstack/service-messaging`, so the edge declares a
433
+ resolution that already resolved rather than adding a package to the graph —
434
+ and relying on it undeclared would have been a phantom dependency under this
435
+ repo's strict package manager.
436
+
437
+ `check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this
438
+ cannot silently come apart again: a bundle set that commits a companion and
439
+ does not consult it at serving time now fails the build, including a tenth set
440
+ that lands tomorrow.
441
+
442
+ **Graded `patch`, and the grade is the interesting part.** No API changes, no
443
+ new exported surface, and no key set moves — substitution was chosen over
444
+ deletion precisely so key-set claims stay put (ruling #8765 Option B). What
445
+ changes is which STRING a stale leaf serves. On this tree that is **zero
446
+ leaves**: a record is only ever written for a leaf that IS a byte copy of the
447
+ current source, so the companions arrive 0-stale by construction. The change is
448
+ in what happens the next time a source string moves — the reader sees the
449
+ English source rather than a superseded draft of it, which is the same
450
+ degradation an untranslated key already produces and not a new state.
451
+ - de47336: chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559)
452
+
453
+ `os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A)
454
+ records, per generated translation leaf, the digest of the source revision that
455
+ leaf is **still a byte copy of** — the one signal that tells a stale fill from a
456
+ real translation once the source has moved and the two stopped being
457
+ distinguishable by value. It shipped opt-in, and exactly one of the nine i18n
458
+ bundle sets opted in. A landed detector, a changeset announcing it and a green
459
+ gate read together as *"generated translation staleness is now caught"*; for
460
+ eight of nine sets it was not, and the thing making it not caught was a single
461
+ absent flag in an extract config — invisible from all three of those surfaces.
462
+
463
+ **All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`,
464
+ `plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`,
465
+ `service-realtime`, `service-storage`. Each documents `source-hashes` in its
466
+ extract config and commits three `<locale>.source-hashes.generated.ts`
467
+ companions, produced by the same extract run as the bundles they sit beside
468
+ (`check:i18n` compares them byte-for-byte, so they cannot be written by hand).
469
+ `check:i18n` now reports 7 bundles per set where it reported 4, and 11 for
470
+ `platform-objects` where it reported 8.
471
+
472
+ **Records count what is currently RECORDABLE, never what is covered.** A record
473
+ is written only for a leaf that *is* right now a byte copy of the current
474
+ source, so a fully translated locale starts with an empty table — which is the
475
+ instrument armed, not an instrument that measures nothing: the entry appears by
476
+ itself on the first extract after a leaf becomes a fill. Measured at this
477
+ commit, per set over its three translated locales: `service-messaging` 289,
478
+ `plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20,
479
+ `plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and
480
+ ja-JP are fully translated and start empty), `service-realtime` 0 (all three
481
+ locales fully translated). **419 records written across the eight sets, 0
482
+ stale.**
483
+
484
+ **One extractor fix the rollout forced.** `--source-hashes` had one user, and
485
+ that user commits both generated sections, so the interaction with
486
+ `--no-metadata-forms` had never been exercised. The provenance table is computed
487
+ over every generated section the extractor builds; the eight sets here commit no
488
+ metadata-forms bundle, and their `metadataForms` subtree — absent from their
489
+ merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf
490
+ of it was recordable. First measured on `plugin-audit`: **763 records, of which
491
+ 2 were its own objects and 761 were digests of the Studio metadata-form baseline
492
+ `@objectstack/platform-objects` owns.** Those records are unreadable in the
493
+ package holding them and would have rewritten all 24 companions on any unrelated
494
+ `*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8
495
+ and every `bundle-ownership.test.ts` keep out of committed bundles. The
496
+ companion now covers exactly the sections a run commits, decided by the same two
497
+ predicates that decide the bundle files. `platform-objects` commits both, so its
498
+ three committed companions are byte-for-byte unchanged.
499
+
500
+ **Grade: `patch`, and behaviour on the day it lands is unchanged for every
501
+ leaf.** A record is written only where a leaf is currently a byte copy of the
502
+ **current** source, so every record written equals the current digest and none
503
+ of them can be stale; the mechanism cannot arrive red. No committed translation
504
+ bundle changed a byte, no public API moved, and no leaf's rendered text changed.
505
+ `narrowToCommittedSections` is new but internal to `@objectstack/cli` — the
506
+ package's entrypoint does not re-export the extractor utils.
507
+
508
+ **What this does not do**, stated so the boundary is not inferred wrongly a
509
+ second time: these eight sets now *record* provenance. Reading it at serving
510
+ time is `withSourceFallback`, and that is still wired in
511
+ `@objectstack/platform-objects` alone — so a stale fill in one of the eight is
512
+ now recorded and reportable, but not yet substituted at runtime. Tracked
513
+ separately.
514
+ - ad54eb3: feat(tooling): onboard all 14 `packages/plugins/**` packages into `check:test-typecheck` (#14062)
515
+
516
+ Every plugin package now has a `tsconfig.test.json` compiled by the shared
517
+ `check:test-typecheck` gate, and its `typecheck` script names it. Before this,
518
+ the shrink-only `test-typecheck-debt.json` ratchet said **nothing** about a
519
+ third of the repo's runtime surface: 14 packages, 1 `tsconfig.test.json`
520
+ (`plugin-security`, wired directly to `tsc` rather than to the instrument), and
521
+ 0 `check:test-typecheck` scripts.
522
+
523
+ Onboarded as a family by the director ruling of 2026-09-01 on #14062
524
+ (maintainer verbatim: 「同意」), which also carries the #5286 maintainer
525
+ authority the starting ledgers need. The smaller branch triage recommended —
526
+ declare the instrument's scope and re-site the two compile-time pins — was
527
+ recorded as considered and not taken: an instrument silent over a third of the
528
+ runtime surface is a hole readers generalise across, and that costs more than
529
+ fourteen tsconfigs.
530
+
531
+ **Measured, not assumed** (at `e80889095`, workspace closure built first). Four
532
+ packages carry residue and therefore a starting ledger — plugin-approvals 324
533
+ over 8 files, plugin-auth 94 over 10, plugin-sharing 3 over 2,
534
+ knowledge-ragflow 3 over 1. The other ten measure **zero** and deliberately get
535
+ no ledger file at all: the gate reads a missing ledger as `{ entries: {} }`, so
536
+ any error there is red immediately with no entry to be added to — strictly
537
+ stronger than a ledger holding nothing, and the call `plugin-security` had
538
+ already recorded for itself.
539
+
540
+ ⛔ **This does not repair 345 type errors.** Per ruling item 3 it makes the
541
+ ratchet able to *see* them; paydown follows the ratchet's own shrink-only
542
+ discipline on its own cards. No test file is edited here.
543
+
544
+ Two corrections to the finding's own prose, both measured: the exclusion is
545
+ narrower than "no plugin package compiles its tests" — 9 of the 14 already
546
+ compiled their tests inside the `typecheck`-invoked build config, at zero
547
+ errors — and `exec-context-annotation.pin.ts` is a `.pin.ts`, which
548
+ `**/*.test.ts` never excluded, so its directives were already live. The pin
549
+ this change genuinely makes real is
550
+ `plugin-approvals/src/manager-org-screen-parity.contract.test.ts`, which no tsc
551
+ program had ever read.
552
+ - 1272f0a: Promote `resolveRecordOrganizationField` to the shared platform-row organization resolver (the cloud#1395 Option A ruling): a platform row's organization is the SUBJECT record's organization; actor context is the fallback, never the primary.
553
+
554
+ - `@objectstack/metadata-core` now owns the resolver (`resolveRecordOrganizationField`, `createFieldPresenceProbe`, and the new memoized `createRecordOrganizationResolver` factory) so all three sanctioned writers share one precedence.
555
+ - `@objectstack/plugin-approvals`: `openNodeRequest` stamps `sys_approval_request`, `sys_approval_action` and the `sys_approval_approver` index from the subject record's organization (acting context as fallback). Fixes the measured defect where every schedule / time-relative / api triggered approval persisted `organization_id = NULL` — locking the record it was about while being invisible in every inbox, its owner's included.
556
+ - `@objectstack/service-automation`: `sys_automation_run` rows (paused and terminal) resolve their organization from the trigger-record snapshot, with the acting tenant as fallback. Terminal rows previously never carried an organization at all.
557
+ - `@objectstack/plugin-audit`: the resolver moved out; the package re-exports it from the original paths, behavior unchanged.
558
+
559
+ The `sys_api_key` divergence is preserved and pinned: `tenancy.organizationField` (who a row is ABOUT) still wins over the tenant wall answer, and the credential table stays unwalled.
560
+ - Updated dependencies [809d417]
561
+ - Updated dependencies [387e231]
562
+ - Updated dependencies [f794e4e]
563
+ - Updated dependencies [cae2169]
564
+ - Updated dependencies [b812a54]
565
+ - Updated dependencies [2d4fa75]
566
+ - Updated dependencies [0e4e51b]
567
+ - Updated dependencies [e84bbf6]
568
+ - Updated dependencies [effae80]
569
+ - Updated dependencies [efb3513]
570
+ - Updated dependencies [d62f990]
571
+ - Updated dependencies [c45d8e6]
572
+ - Updated dependencies [2e3e8c7]
573
+ - Updated dependencies [e621291]
574
+ - Updated dependencies [655b106]
575
+ - Updated dependencies [40a93b5]
576
+ - Updated dependencies [101ad2c]
577
+ - Updated dependencies [d5b330d]
578
+ - Updated dependencies [dda969c]
579
+ - Updated dependencies [1f45690]
580
+ - Updated dependencies [277948f]
581
+ - Updated dependencies [8bdd955]
582
+ - Updated dependencies [54e2d36]
583
+ - Updated dependencies [b745157]
584
+ - Updated dependencies [f3bbbef]
585
+ - Updated dependencies [4f24e9d]
586
+ - Updated dependencies [e27583e]
587
+ - Updated dependencies [4bd6faa]
588
+ - Updated dependencies [86cbe37]
589
+ - Updated dependencies [6a180e4]
590
+ - Updated dependencies [474242f]
591
+ - Updated dependencies [63cd487]
592
+ - Updated dependencies [bd4aa4e]
593
+ - Updated dependencies [803eaab]
594
+ - Updated dependencies [f8e8f03]
595
+ - Updated dependencies [983edf1]
596
+ - Updated dependencies [eae824e]
597
+ - Updated dependencies [f6fa22c]
598
+ - Updated dependencies [8a483b3]
599
+ - Updated dependencies [3bc2e38]
600
+ - Updated dependencies [97bcd99]
601
+ - Updated dependencies [df59de0]
602
+ - Updated dependencies [96e25a8]
603
+ - Updated dependencies [713f83f]
604
+ - Updated dependencies [77d4b3c]
605
+ - Updated dependencies [f75a38a]
606
+ - Updated dependencies [7a25e7d]
607
+ - Updated dependencies [1fa05a6]
608
+ - Updated dependencies [c85a265]
609
+ - Updated dependencies [dcb10a5]
610
+ - Updated dependencies [773a999]
611
+ - Updated dependencies [35dffea]
612
+ - Updated dependencies [d8024f0]
613
+ - Updated dependencies [8120808]
614
+ - Updated dependencies [776a098]
615
+ - Updated dependencies [5060877]
616
+ - Updated dependencies [4f6325d]
617
+ - Updated dependencies [52954c0]
618
+ - Updated dependencies [2aa8456]
619
+ - Updated dependencies [d23ebb9]
620
+ - Updated dependencies [93809a3]
621
+ - Updated dependencies [7c0d0c3]
622
+ - Updated dependencies [daae7aa]
623
+ - Updated dependencies [8dc22d6]
624
+ - Updated dependencies [fa5d137]
625
+ - Updated dependencies [a392dbf]
626
+ - Updated dependencies [279431e]
627
+ - Updated dependencies [948dd6b]
628
+ - Updated dependencies [3b4c56c]
629
+ - Updated dependencies [ae8edd2]
630
+ - Updated dependencies [e25403c]
631
+ - Updated dependencies [a81aa9d]
632
+ - Updated dependencies [64baa68]
633
+ - Updated dependencies [9fa70d7]
634
+ - Updated dependencies [09db64a]
635
+ - Updated dependencies [92916e7]
636
+ - Updated dependencies [a84f3ea]
637
+ - Updated dependencies [f2eaae8]
638
+ - Updated dependencies [56c093c]
639
+ - Updated dependencies [c09451b]
640
+ - Updated dependencies [ba64877]
641
+ - Updated dependencies [e7191ce]
642
+ - Updated dependencies [7345308]
643
+ - Updated dependencies [79b6a22]
644
+ - Updated dependencies [30d96ab]
645
+ - Updated dependencies [f658793]
646
+ - Updated dependencies [0fd4899]
647
+ - Updated dependencies [c95ad19]
648
+ - Updated dependencies [e58ea8b]
649
+ - Updated dependencies [4a17645]
650
+ - Updated dependencies [3795c5f]
651
+ - Updated dependencies [8ab926b]
652
+ - Updated dependencies [7317cf2]
653
+ - Updated dependencies [e25e839]
654
+ - Updated dependencies [5997207]
655
+ - Updated dependencies [8b13cc8]
656
+ - Updated dependencies [00d8f65]
657
+ - Updated dependencies [4a4a35d]
658
+ - Updated dependencies [4a4a35d]
659
+ - Updated dependencies [86e765a]
660
+ - Updated dependencies [1d7e76a]
661
+ - Updated dependencies [53dc739]
662
+ - Updated dependencies [fd289be]
663
+ - Updated dependencies [03bf7b1]
664
+ - Updated dependencies [f90e820]
665
+ - Updated dependencies [18d816a]
666
+ - Updated dependencies [e8bd715]
667
+ - Updated dependencies [b91c351]
668
+ - Updated dependencies [a28a3c0]
669
+ - Updated dependencies [200d255]
670
+ - Updated dependencies [2852acc]
671
+ - Updated dependencies [daeaaf9]
672
+ - Updated dependencies [c459da6]
673
+ - Updated dependencies [e914733]
674
+ - Updated dependencies [1d8ad0f]
675
+ - Updated dependencies [9738c35]
676
+ - Updated dependencies [f887e52]
677
+ - Updated dependencies [881f8d8]
678
+ - Updated dependencies [3bfa1e6]
679
+ - Updated dependencies [0a8ebf3]
680
+ - Updated dependencies [901355c]
681
+ - Updated dependencies [34ce8e7]
682
+ - Updated dependencies [33681ea]
683
+ - Updated dependencies [bfe13c8]
684
+ - Updated dependencies [0fb3044]
685
+ - Updated dependencies [4635f3e]
686
+ - Updated dependencies [fd289be]
687
+ - Updated dependencies [ee3595c]
688
+ - Updated dependencies [09b4f4e]
689
+ - Updated dependencies [b2eab95]
690
+ - Updated dependencies [93940d4]
691
+ - Updated dependencies [3a04b01]
692
+ - Updated dependencies [45b9051]
693
+ - Updated dependencies [3954fb7]
694
+ - Updated dependencies [4805b56]
695
+ - Updated dependencies [b9e9227]
696
+ - Updated dependencies [d395692]
697
+ - Updated dependencies [5894d30]
698
+ - Updated dependencies [a3765f6]
699
+ - Updated dependencies [2d5cee3]
700
+ - Updated dependencies [e22158f]
701
+ - Updated dependencies [7404925]
702
+ - Updated dependencies [0c2334f]
703
+ - Updated dependencies [778c59f]
704
+ - Updated dependencies [d2619fd]
705
+ - Updated dependencies [af56546]
706
+ - Updated dependencies [6acb11a]
707
+ - Updated dependencies [33c5fd3]
708
+ - Updated dependencies [20b0fdb]
709
+ - Updated dependencies [905019b]
710
+ - Updated dependencies [a286411]
711
+ - Updated dependencies [98c0d33]
712
+ - Updated dependencies [368a82e]
713
+ - Updated dependencies [a3d5724]
714
+ - Updated dependencies [93ea19b]
715
+ - Updated dependencies [9ee2dcf]
716
+ - Updated dependencies [8cb96ec]
717
+ - Updated dependencies [8f10a79]
718
+ - Updated dependencies [6269a55]
719
+ - Updated dependencies [a17da05]
720
+ - Updated dependencies [a8c00e2]
721
+ - Updated dependencies [22e5236]
722
+ - Updated dependencies [0fb8760]
723
+ - Updated dependencies [e5ce2ed]
724
+ - Updated dependencies [be21955]
725
+ - Updated dependencies [bc56e18]
726
+ - Updated dependencies [be21955]
727
+ - Updated dependencies [a9ee989]
728
+ - Updated dependencies [4d0d944]
729
+ - Updated dependencies [15d58db]
730
+ - Updated dependencies [d63b014]
731
+ - Updated dependencies [9abe4e4]
732
+ - Updated dependencies [2cc7122]
733
+ - Updated dependencies [50d6c92]
734
+ - Updated dependencies [15d55fb]
735
+ - Updated dependencies [9e0ba21]
736
+ - Updated dependencies [311433f]
737
+ - Updated dependencies [3e5ad08]
738
+ - Updated dependencies [9abe4e4]
739
+ - Updated dependencies [b7131f3]
740
+ - Updated dependencies [e5812fa]
741
+ - Updated dependencies [7085f90]
742
+ - Updated dependencies [dee4dd4]
743
+ - Updated dependencies [ce7e497]
744
+ - Updated dependencies [51ecb2f]
745
+ - Updated dependencies [9086761]
746
+ - Updated dependencies [f6344e7]
747
+ - Updated dependencies [42a117b]
748
+ - Updated dependencies [1401ae7]
749
+ - Updated dependencies [4297fe7]
750
+ - Updated dependencies [e398863]
751
+ - Updated dependencies [d16df74]
752
+ - Updated dependencies [d79c602]
753
+ - Updated dependencies [f11fc61]
754
+ - Updated dependencies [e808890]
755
+ - Updated dependencies [8f79379]
756
+ - Updated dependencies [e6ca40e]
757
+ - Updated dependencies [0c77ea4]
758
+ - Updated dependencies [52954c0]
759
+ - Updated dependencies [89eb997]
760
+ - Updated dependencies [7131f12]
761
+ - Updated dependencies [aa5994e]
762
+ - Updated dependencies [be93457]
763
+ - Updated dependencies [a65db76]
764
+ - Updated dependencies [2cf5a96]
765
+ - Updated dependencies [15eb2c9]
766
+ - Updated dependencies [5691b07]
767
+ - Updated dependencies [2a6122b]
768
+ - Updated dependencies [225e769]
769
+ - Updated dependencies [8af88dd]
770
+ - Updated dependencies [fb5fbb8]
771
+ - Updated dependencies [d7b3963]
772
+ - Updated dependencies [33184fd]
773
+ - Updated dependencies [7c41693]
774
+ - Updated dependencies [b72db01]
775
+ - Updated dependencies [dce5cd4]
776
+ - Updated dependencies [9688f58]
777
+ - Updated dependencies [556ebc1]
778
+ - Updated dependencies [177ebdc]
779
+ - Updated dependencies [8d237b4]
780
+ - Updated dependencies [2d2e6f0]
781
+ - Updated dependencies [2d8dd8d]
782
+ - Updated dependencies [22d573e]
783
+ - Updated dependencies [b5a2398]
784
+ - Updated dependencies [348860c]
785
+ - Updated dependencies [5383fa6]
786
+ - Updated dependencies [5b3ff63]
787
+ - Updated dependencies [1a6a19c]
788
+ - Updated dependencies [064d484]
789
+ - Updated dependencies [527e050]
790
+ - Updated dependencies [dd33bf9]
791
+ - Updated dependencies [4cb2a90]
792
+ - Updated dependencies [74a7804]
793
+ - Updated dependencies [53d3689]
794
+ - Updated dependencies [b3a63d3]
795
+ - Updated dependencies [49f0dcf]
796
+ - Updated dependencies [033a34c]
797
+ - Updated dependencies [4d25d22]
798
+ - Updated dependencies [1ffee51]
799
+ - Updated dependencies [5ae4303]
800
+ - Updated dependencies [ece4dad]
801
+ - Updated dependencies [e9b377e]
802
+ - Updated dependencies [146f448]
803
+ - Updated dependencies [735f5c7]
804
+ - Updated dependencies [a7e18de]
805
+ - Updated dependencies [366f895]
806
+ - Updated dependencies [dc75ba8]
807
+ - Updated dependencies [cce0aa9]
808
+ - Updated dependencies [e764507]
809
+ - Updated dependencies [cff17af]
810
+ - Updated dependencies [39404f3]
811
+ - Updated dependencies [ca1965f]
812
+ - Updated dependencies [8619f95]
813
+ - Updated dependencies [b706af9]
814
+ - Updated dependencies [db8c288]
815
+ - Updated dependencies [0e5fe7f]
816
+ - Updated dependencies [add4360]
817
+ - Updated dependencies [e0abc38]
818
+ - Updated dependencies [fc9ba76]
819
+ - Updated dependencies [1272f0a]
820
+ - Updated dependencies [0f94cc7]
821
+ - Updated dependencies [a11c1a5]
822
+ - Updated dependencies [71f9cd1]
823
+ - Updated dependencies [ee17d86]
824
+ - Updated dependencies [cdbd920]
825
+ - Updated dependencies [18c432e]
826
+ - Updated dependencies [3c418c4]
827
+ - Updated dependencies [fa8715a]
828
+ - Updated dependencies [a933ed7]
829
+ - Updated dependencies [b3ca463]
830
+ - Updated dependencies [a933ed7]
831
+ - Updated dependencies [0d4a6a8]
832
+ - Updated dependencies [518d5e5]
833
+ - Updated dependencies [6643ba1]
834
+ - Updated dependencies [eeba2ef]
835
+ - Updated dependencies [ec4c4d2]
836
+ - Updated dependencies [424f73c]
837
+ - Updated dependencies [cccbe51]
838
+ - Updated dependencies [a8d6b1d]
839
+ - Updated dependencies [e4a7695]
840
+ - Updated dependencies [87075b1]
841
+ - Updated dependencies [fc58a99]
842
+ - Updated dependencies [14cfc00]
843
+ - Updated dependencies [1c6f7b4]
844
+ - Updated dependencies [e854a53]
845
+ - Updated dependencies [dfebfc8]
846
+ - Updated dependencies [598b7ec]
847
+ - Updated dependencies [d028b37]
848
+ - Updated dependencies [f7b25c5]
849
+ - Updated dependencies [122ef38]
850
+ - Updated dependencies [4a37870]
851
+ - Updated dependencies [428f9b2]
852
+ - Updated dependencies [aa7ff56]
853
+ - Updated dependencies [811a3c2]
854
+ - Updated dependencies [1401ae7]
855
+ - Updated dependencies [2fd3f1c]
856
+ - Updated dependencies [c41b42e]
857
+ - Updated dependencies [d41d166]
858
+ - Updated dependencies [c4db311]
859
+ - Updated dependencies [750fff5]
860
+ - Updated dependencies [c19035e]
861
+ - Updated dependencies [ececf7a]
862
+ - Updated dependencies [d173125]
863
+ - Updated dependencies [8eeca27]
864
+ - Updated dependencies [8425c17]
865
+ - Updated dependencies [a5ef1d8]
866
+ - Updated dependencies [87ad30c]
867
+ - Updated dependencies [772d5de]
868
+ - Updated dependencies [ce80ec2]
869
+ - Updated dependencies [b372318]
870
+ - Updated dependencies [97a2263]
871
+ - Updated dependencies [29d0676]
872
+ - Updated dependencies [0169d49]
873
+ - Updated dependencies [6bd3231]
874
+ - Updated dependencies [d2b5ba8]
875
+ - Updated dependencies [b799ac5]
876
+ - Updated dependencies [8f74307]
877
+ - Updated dependencies [d23dc08]
878
+ - Updated dependencies [038f333]
879
+ - Updated dependencies [644ad50]
880
+ - Updated dependencies [9735662]
881
+ - Updated dependencies [4d5b4f8]
882
+ - Updated dependencies [5d16379]
883
+ - Updated dependencies [0da7cd2]
884
+ - Updated dependencies [28a5c3e]
885
+ - Updated dependencies [4bc18e5]
886
+ - Updated dependencies [9f57f1e]
887
+ - @objectstack/spec@17.3.0
888
+ - @objectstack/platform-objects@17.3.0
889
+ - @objectstack/core@17.3.0
890
+ - @objectstack/types@17.3.0
891
+ - @objectstack/metadata-core@17.3.0
892
+ - @objectstack/formula@17.3.0
893
+
894
+ ## 17.2.0
895
+
896
+ ### Minor Changes
897
+
898
+ - b47ba2c: **BREAKING** (compile-time only): `ApprovalServiceOptions['logger']` now
899
+ declares a **non-optional** `warn`, so a durability report always has
900
+ somewhere to land (#9754, #10556). This is the thirteenth of the thirteen
901
+ mechanical repairs the card names — held out of #10691 to serialize against
902
+ PR #10547, which owned `approval-service.ts` while it was open; that fence
903
+ has since cleared.
904
+
905
+ `minor`, not `major`: during the launch window this stack ships breaking
906
+ changes as `minor` — every publishable package versions in lockstep, so a
907
+ `major` would promote the whole release. `patch` would be wrong in the other
908
+ direction, because this *can* break a consumer's build. This is the same
909
+ reasoning #10691 used for the twelve sibling repairs; no exemption for a
910
+ types-only break was found there either, and none applies here.
911
+
912
+ `error` stays optional — hosts legitimately inject reduced sinks, and
913
+ requiring `error` was measured and rejected as #9754 option C. What changes
914
+ is that its *absence* now has a declared, guaranteed destination. Call sites
915
+ keep the `logger?.warn?.(…)` spelling as the backstop for hosts the type
916
+ cannot reach, so **no runtime behaviour changes**: nothing that printed
917
+ before stops printing, and nothing silent starts printing.
918
+
919
+ ### Who has to change, and what to do
920
+
921
+ Only a caller that constructs `ApprovalService` (or an `ApprovalServiceOptions`
922
+ value) with a `logger` object that has **no `warn` method** — for example
923
+ `{ error }` alone. Add a `warn` member; there is no rename, no removal, and no
924
+ stored value or metadata key to rewrite. The only non-test construction site
925
+ in this repo (`ApprovalsServicePlugin.start`, in this same package) passes the
926
+ kernel `ctx.logger`, whose `warn` is already required, so the in-repo cost is
927
+ zero.
928
+
929
+ <!-- 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. -->
930
+ - 13f533a: fix(approvals): screen the `manager` approver to the request's organization (#10153)
931
+
932
+ `expandApprovers` hands the directory organization to every graph-shaped
933
+ approver expansion — `department`, `position`, `org_membership_level`. The
934
+ `manager` branch did not: `lookupManager` read `sys_user.manager_id` under a
935
+ system context and took no organization argument at all. `sys_user` is a global
936
+ identity table with no `organization_id`, so nothing else on that path supplied
937
+ the tenancy fact either. A `manager_id` crossing an organization boundary
938
+ therefore routed the submission to an approver **in another organization** — an
939
+ out-of-tenant person granted approval authority over the record.
940
+
941
+ The same column has been screened on the hierarchy side since cloud#1195. This
942
+ brings the approvals consumer into line for the `manager` branch.
943
+
944
+ ## What the screen is
945
+
946
+ `lookupManager(userId, organizationId)` now resolves the manager and then asks
947
+ whether he is **provably outside** the request's organization:
948
+
949
+ | membership rows for the manager | result |
950
+ |---|---|
951
+ | some exist, none in the request's org | **screened out** — the slot falls through to the `manager:<value>` literal |
952
+ | one is in the request's org | resolves, unchanged |
953
+ | none exist at all | resolves, unchanged — the tenancy fact is absent, not negative |
954
+ | the `sys_member` read failed | resolves, unchanged |
955
+ | the request carries no organization | resolves, unchanged — and no read is performed |
956
+
957
+ The fail-open half is this file's ruled posture on addressing paths, stated
958
+ twice already: `filterApproversWhoCanRead` refuses to empty a live slate on an
959
+ infrastructure hiccup, and `expandPositionUsers` carries "a step routing to
960
+ nobody is worse than one routing to a lapsed holder". A drop is logged with the
961
+ manager's id, his organizations and the request's, so the fix ("repair the link"
962
+ / "grant the membership" / "retarget the step") is legible without a debugger.
963
+
964
+ ## ⚠️ This moves one input from accepted to refused
965
+
966
+ A node whose **sole** approver is a cross-org `manager` and which is authored
967
+ with the **non-default** `onEmptyApprovers: 'fail'` used to open successfully;
968
+ it now throws `NO_APPROVERS`. Nothing new is thrown — a screened-out manager
969
+ leaves only a `type:value` literal, which the pre-existing empty-slate test
970
+ already classifies as empty, and `'fail'` already throws on empty. Every
971
+ screened sibling has reached that same bucket since it was written.
972
+
973
+ **The default policy is unaffected**: `admin_rescue` still opens the request
974
+ (decidable by a privileged admin) and warns, and `auto_approve` still
975
+ auto-approves. Both directions and both policies are pinned in
976
+ `manager-approver-org-screen.test.ts`.
977
+
978
+ ## What this does NOT decide
979
+
980
+ - **#7497** (does approver routing imply record read visibility?) stays open.
981
+ The screen reads `sys_member`, which looks like the D2 read filter beside it,
982
+ and the code says at length why it is the *sibling* treatment instead: two of
983
+ the three org-scoped expansions already screen on `sys_member.organization_id`,
984
+ and `sys_user` offers no other tenancy fact. No reads are granted and no read
985
+ screen is applied to any type that lacked one.
986
+ - **`team`** is still unscreened — it is a sibling graph expansion that is not
987
+ org-scoped either, tracked as #10230, and it touches this same file.
988
+ - `APPROVER_ORG_SCOPED` is untouched. It answers ADR-0105 D9 *retargetability*
989
+ (may an author write `organization:` on this type?), not screening, and
990
+ `manager: false` remains correct.
991
+ - e222a53: **BREAKING** (compile-time only): twelve logger sink types that declared an
992
+ optional `error` now declare a **non-optional** `warn`, so a durability report
993
+ always has somewhere to land (#9754, #10556).
994
+
995
+ `minor`, not `major`: during the launch window this stack ships breaking changes
996
+ as `minor` — every publishable package versions in lockstep, so a `major` would
997
+ promote the whole release. `patch` would be wrong in the other direction, because
998
+ this *can* break a consumer's build.
999
+
1000
+ `error` stays optional on every one of these types — hosts legitimately inject
1001
+ reduced sinks, and requiring `error` was measured and rejected as #9754 option C.
1002
+ What changes is that its *absence* now has a declared, guaranteed destination.
1003
+ Call sites keep the `logger?.warn?.(…)` spelling as the backstop for hosts the
1004
+ type cannot reach, so **no runtime behaviour changes**: nothing that printed
1005
+ before stops printing, and nothing silent starts printing.
1006
+
1007
+ ### Who has to change, and what to do
1008
+
1009
+ Only a caller that hands one of these sinks an object with **no `warn` method** —
1010
+ for example `{ info }` or `{ error }` alone. Add a `warn` member; there is no
1011
+ rename, no removal, and no stored value or metadata key to rewrite. Every
1012
+ construction site inside this repo already supplied one, so the in-repo cost was
1013
+ zero; the compile error is reserved for the callers that were silently discarding
1014
+ these reports.
1015
+
1016
+ The affected types, by package:
1017
+
1018
+ - `@objectstack/cloud-connection` — the internal `PluginContext['logger']`
1019
+ - `@objectstack/metadata-protocol` — `IndexMigrationLogger`
1020
+ - `@objectstack/plugin-approvals` — the internal `MinimalLogger` of `lifecycle-hooks`
1021
+ - `@objectstack/plugin-audit` — `AuthEventAuditLogger`, `ReadAuditLogger`
1022
+ - `@objectstack/plugin-auth` — `ReconcileMembershipDeps['logger']`, the internal
1023
+ `LoggerLike` of `member-role-canonical`, and `AuthManagerOptions['logger']`
1024
+ - `@objectstack/plugin-email` — `ReclaimLogger`, via `ReclaimAttachmentContentOptions`
1025
+ - `@objectstack/plugin-reports` — `ReportServiceOptions['logger']`
1026
+ - `@objectstack/plugin-sharing` — the internal `MinimalLogger` of `bulk-recompute`,
1027
+ `rule-hooks` and `record-share-cascade`
1028
+ - `@objectstack/plugin-webhooks` — `OptionalLogger`, via `AutoEnqueuerOptions`
1029
+ - `@objectstack/service-knowledge` — `KnowledgeLogger`
1030
+
1031
+ `AuthManagerOptions['logger']` is the one most likely to be reached from outside:
1032
+ `AuthManager` is public surface, its `logger` option stays optional, and a logger
1033
+ that *is* supplied must now carry `warn`. The only non-test construction site in
1034
+ this repo passes the kernel `Logger`, whose `warn` is already required.
1035
+
1036
+ `ReportService` and `AutoEnqueuer` additionally stopped defaulting their logger
1037
+ field to `{}`. The field is now honestly optional rather than holding an empty
1038
+ object that declared it could report and discarded everything. Behaviour is
1039
+ unchanged in both directions.
1040
+
1041
+ <!-- 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. -->
1042
+
1043
+ ### Patch Changes
1044
+
1045
+ - 07bd1ca: Apply the subject object's field-level read controls to the approval payload
1046
+ snapshot at serve time (#10749), so an approver no longer receives fields the
1047
+ app author declared they may not read.
1048
+
1049
+ `sys_approval_request.payload_json` stores the submitted record's raw row,
1050
+ captured from the flow's `$record` variable — which the automation layer hands
1051
+ over with the record's own FLS never applying. The column is a `textarea` on
1052
+ `sys_approval_request`, so to every read door it is an opaque string: the
1053
+ field-visibility machinery governs *columns of objects* and cannot see inside a
1054
+ JSON column. Every field-level read control declared on the SUBJECT object —
1055
+ `requiredPermissions` (ADR-0066 D3), a permission set marking a field
1056
+ non-readable, a `maskingRule` — was therefore unenforceable on the approval
1057
+ path, for every app.
1058
+
1059
+ Per the maintainer's ruling (2026-08-22, Option B) the full snapshot **stays at
1060
+ rest**: the approval record remains audit evidence of what was actually
1061
+ submitted, which write-time trimming would have given away. Redaction happens at
1062
+ **serve** time, keyed on the reading caller, so the same row answers an admin
1063
+ with the whole snapshot and a restricted approver with only the fields they may
1064
+ read. The readable set is not recomputed — it comes from the security service's
1065
+ `getReadableFields`, documented as the same field mask the read middleware
1066
+ applies, so this seam cannot drift from data-plane FLS.
1067
+
1068
+ Two doors are covered, because `payload_json` has two independent readers and a
1069
+ seam covering one manufactures the belief that the path is masked:
1070
+
1071
+ - the **service door** (`getRequest` / `listRequests`, behind
1072
+ `GET /api/v1/approvals/requests[/:id]`), which serves the parsed `payload`.
1073
+ Redaction runs BEFORE display enrichment, so `payload_display` and
1074
+ `payload_labels` — both built by walking the snapshot's own keys — cannot ship
1075
+ a restricted field's name, its authored label, or the title of the record it
1076
+ points at;
1077
+ - the **generic data door**: the object declares
1078
+ `enable.apiMethods: ['get','list']`, so a plain `find`/`findOne` returns the
1079
+ raw string without the service ever running. Covered by object-scoped engine
1080
+ middleware, which reaches the whole family sharing that producer (REST data
1081
+ routes, ObjectQL, CSV/XLSX export, MCP). Middleware rather than an `afterFind`
1082
+ hook on purpose: a hook receives `buildSession`'s output, which carries no
1083
+ `onBehalfOf`, so a hook-based seam would drop the ADR-0090 D10 delegator
1084
+ intersection and answer a delegated read more permissively than the service.
1085
+
1086
+ **Behaviour change, argued rather than assumed.** A non-admin caller that was
1087
+ reading restricted keys out of the snapshot now receives fewer keys, and that is
1088
+ a real change for such a consumer. It is shipped as a fix rather than a breaking
1089
+ change because those fields were never that caller's to read: the approval path
1090
+ was a bypass of a declaration the platform enforces everywhere else, and the
1091
+ served type is `payload?: unknown` — never a promised field set. This follows the
1092
+ `__search` companion strip (#7642), which shipped the same way on the same
1093
+ reasoning. Object-level access is deliberately untouched: an approver commonly
1094
+ holds no read grant on the object under approval at all, and
1095
+ `getReadableFields` answers a caller with no field-permission entries with the
1096
+ full set, so every approval drawer shipping today keeps rendering.
1097
+
1098
+ `hidden: true` is deliberately NOT acted on. It is a UI contract ("Hidden from
1099
+ default UI") which, in the spec's own words, "has never governed serialization"
1100
+ — measurably: no read path in the repo strips a value on it. Enforcing it here
1101
+ alone would make the approval path stricter than a direct read of the same row
1102
+ (closing no leak, since the approver can simply read the record) while breaking
1103
+ drawers that render a `hidden` business column. That is a `packages/spec`
1104
+ semantics question and is left open.
1105
+ - f0d7647: **Deliberate search-semantics change.** `ApprovalService.listRequests` /
1106
+ `countRequests` no longer push a free-text predicate onto the payload snapshot
1107
+ column for a caller whose view of that snapshot is masked (#11040).
1108
+
1109
+ Since #10749 the approval snapshot (`sys_approval_request.payload_json`, the
1110
+ submitted record's row) is redacted **at serve time, per reader**: each row is
1111
+ cut down to the fields that caller may read on that row's subject object. The
1112
+ full row deliberately stays at rest, so the approval record remains audit
1113
+ evidence of what was actually submitted.
1114
+
1115
+ The free-text filter, however, is evaluated by the driver against the stored
1116
+ column — before anything is served, and therefore against the unmasked bytes. A
1117
+ predicate over that column is a question about its contents whose answer is row
1118
+ membership, so the field-level read controls #10749 enforces on the way out did
1119
+ not hold on the way in. That is `declared ≠ enforced`, and the platform has
1120
+ already settled the governing principle for it: under `maskingRule` (#8993) a
1121
+ field a caller sees masked is non-filterable, refused loudly, because otherwise
1122
+ equality probes reconstruct the hidden span. This extends that settled posture
1123
+ to the snapshot column, which is reached through a different door.
1124
+
1125
+ **What changes.** For a caller whose view of the snapshot is masked, free-text
1126
+ search matches on `process_name`, `object_name`, `record_id` and `submitter_id`
1127
+ — the columns of `sys_approval_request` itself, which anyone who can see the row
1128
+ reads whole — and no longer on snapshot contents. Such a caller can still find a
1129
+ request by process, object, record id or submitter; they can no longer find one
1130
+ by a value they may not read. Rows returned, their order and pagination are
1131
+ otherwise untouched, and no query is refused: the change only ever removes one
1132
+ disjunct, never denies.
1133
+
1134
+ **What does not change.** A caller the serve path hands the whole snapshot to
1135
+ keeps today's behaviour exactly — same rows, same order. That includes every
1136
+ deployment that has not wired a field-visibility authority (the seam is
1137
+ late-bound, and absent it snapshots are served unredacted), and the case where a
1138
+ wired authority declines to narrow. Consistency with the serve path is the rule
1139
+ here rather than blanket fail-closed: where serve hands over the whole snapshot,
1140
+ keeping the predicate discloses nothing serve does not already disclose.
1141
+
1142
+ The masked/unmasked verdict is read from **the same authority and the same
1143
+ per-caller call the serve path uses**, asked as the caller. It is deliberately
1144
+ not a second, independently derived notion of "redacted" — two derivations drift,
1145
+ and the drift between a serve rule and a filter rule is exactly what this fixes.
1146
+
1147
+ Because redaction is decided per row while a filter is built before any row
1148
+ exists, the predicate-time scope matters: with an `object` filter the subject
1149
+ object is known and the seam is asked about it directly; without one the query
1150
+ spans every object, nothing sound can be asked, and the disjunct is dropped.
1151
+
1152
+ Held by `approval-free-text-scope.test.ts`.
1153
+ - 6d5c4fa: Release these plugins' resources from `destroy()`, the teardown hook the kernel
1154
+ actually calls (#10371). `Plugin` declares `init()`, `start?(ctx)` and
1155
+ `destroy?()` — and no `stop()` — so `ObjectKernel.performShutdown()` and
1156
+ `LiteKernel.destroy()`, which walk the plugins in reverse calling
1157
+ `plugin.destroy()`, walked straight past every plugin whose teardown was spelled
1158
+ `stop()`. `await kernel.shutdown()` resolved with the reports dispatcher still
1159
+ armed, the REST/OpenAPI/Slack connectors still registered on the automation
1160
+ engine, the approvals SLA escalation job still scheduled, and the knowledge
1161
+ event-sync subscription still open.
1162
+
1163
+ Each teardown body now lives in `destroy()`. `stop()` is retained as a
1164
+ delegating alias with its parameter made optional, so an embedder that learned
1165
+ to call it directly — precisely because the kernel never did — keeps working
1166
+ unchanged. No export is removed and the `Plugin` interface is untouched.
1167
+
1168
+ Same defect as #9371 in `@objectstack/service-messaging`, which surfaced as
1169
+ fully green test runs exiting 1 on `EnvironmentTeardownError` and being evicted
1170
+ from the merge queue.
1171
+ - aa765b9: **Who loses access:** members of a team belonging to a *different* organization
1172
+ than the record being approved. Concretely — a request raised in `org_a` routed
1173
+ to a `team` approver whose `sys_team.organization_id` is `org_b` used to place
1174
+ every `sys_team_member` of that team into `pending_approvers`, giving them the
1175
+ approve/reject buttons on a record they are not a tenant of. They no longer
1176
+ enter the slate, and the step falls back to the dead `team:<id>` literal with
1177
+ the existing `#3807` "expanded to nobody" warning — the same shape a cross-org
1178
+ `position` approver has always produced (#10230).
1179
+
1180
+ `team` was the last approver expansion that resolved people without asking
1181
+ which organization was asking; `department`, `position`, `org_membership_level`
1182
+ and (since #10153) `manager` all do. The screen reads the team's own
1183
+ `organization_id`, so it costs one row and a team that fails it never fans out.
1184
+
1185
+ **Who does not lose access**, deliberately: a team stamped with the request's
1186
+ own organization; a team stamped with **no** organization (`organization_id:
1187
+ null` on a platform object means "owned by no organization" — what a seed
1188
+ writes, since a seed cannot know the id the runtime mints at boot); a team id
1189
+ with no `sys_team` row at all; and any request that carries no organization —
1190
+ all four leave routing exactly as it was, because the tenancy fact is absent
1191
+ rather than negative.
1192
+
1193
+ ⚠️ One externally observable accept→reject change beyond the routing itself:
1194
+ under the non-default `onEmptyApprovers: 'fail'` policy, a node whose *sole*
1195
+ approver was a cross-org team used to open a request and now throws
1196
+ `NO_APPROVERS`. Under the default (`admin_rescue`) the node still opens.
1197
+ - c5d0c2f: Screen expanded `team` approver members to the request's organization (#10547).
1198
+
1199
+ #10230 made a `team` approver prove the TEAM's tenancy, and deferred the
1200
+ members on purpose. `sys_team_member` carries `team_id` and `user_id` and no
1201
+ organization column, so a team that passed that screen still routed every user
1202
+ id it listed — including a user whose only `sys_member` row is in another
1203
+ organization. Measured on a fixture, not read off the schema: an `org_a`
1204
+ request against an `org_a` team returned `["u_outsider","u_insider"]` with zero
1205
+ `sys_member` reads.
1206
+
1207
+ The expansion now screens the members with the same provably-outside posture
1208
+ the neighbouring screens pin, in ONE `$in` read for the whole slate:
1209
+
1210
+ - membership rows exist for the user and none is the request's organization
1211
+ (present and NEGATIVE) — dropped, with a warning naming the users, the team
1212
+ and both organizations;
1213
+ - no membership rows, an unreadable `sys_member`, a possibly-truncated read, or
1214
+ a request carrying no organization (ABSENT) — routing is left exactly as it
1215
+ was, and the no-organization case performs no read at all.
1216
+
1217
+ Holding membership elsewhere is not disqualifying; holding none here is.
1218
+
1219
+ ⚠️ Behaviour change, confined to one non-default policy: a node whose only
1220
+ approver is a team staffed entirely by users provably outside the organization
1221
+ now resolves to no one. Under the default `onEmptyApprovers: 'admin_rescue'` it
1222
+ still opens, routed to the dead `team:<id>` literal as any unresolved slate is;
1223
+ under `onEmptyApprovers: 'fail'` it now throws `NO_APPROVERS` where it
1224
+ previously opened.
1225
+
1226
+ Residual condition on the security value: the screen can only act on tenancy
1227
+ facts that exist. A deployment that stamps an organization on its approval
1228
+ requests but does not materialize `sys_member` rows sees no change — by design,
1229
+ since #3807 recorded what treating an absent fact as a negative one costs.
1230
+ - Updated dependencies [8f04d9a]
1231
+ - Updated dependencies [6936d07]
1232
+ - Updated dependencies [59eb04d]
1233
+ - Updated dependencies [9f05b7d]
1234
+ - Updated dependencies [3b2af5e]
1235
+ - Updated dependencies [7d2d112]
1236
+ - Updated dependencies [5fa0d72]
1237
+ - Updated dependencies [8cc8401]
1238
+ - Updated dependencies [02b3b07]
1239
+ - Updated dependencies [46d34ab]
1240
+ - Updated dependencies [914c413]
1241
+ - Updated dependencies [55809a0]
1242
+ - Updated dependencies [ee2ff45]
1243
+ - Updated dependencies [47cd3ec]
1244
+ - Updated dependencies [52db1d1]
1245
+ - Updated dependencies [5649efb]
1246
+ - Updated dependencies [9d7d2de]
1247
+ - Updated dependencies [c815c50]
1248
+ - Updated dependencies [795ea05]
1249
+ - Updated dependencies [2306a76]
1250
+ - Updated dependencies [e5ea701]
1251
+ - Updated dependencies [26f3588]
1252
+ - Updated dependencies [a40dcc1]
1253
+ - Updated dependencies [def0d3e]
1254
+ - Updated dependencies [8d0bb79]
1255
+ - Updated dependencies [5acb58d]
1256
+ - Updated dependencies [2e3cf95]
1257
+ - Updated dependencies [4c93387]
1258
+ - Updated dependencies [504c8d5]
1259
+ - Updated dependencies [a037f7c]
1260
+ - Updated dependencies [3ee8ddf]
1261
+ - Updated dependencies [16cef97]
1262
+ - Updated dependencies [a79bd35]
1263
+ - Updated dependencies [6ceaa4b]
1264
+ - Updated dependencies [15ea214]
1265
+ - Updated dependencies [de19489]
1266
+ - Updated dependencies [c684d00]
1267
+ - Updated dependencies [923c424]
1268
+ - Updated dependencies [0ab81d1]
1269
+ - Updated dependencies [1ec36b7]
1270
+ - Updated dependencies [5f2e54c]
1271
+ - Updated dependencies [189373b]
1272
+ - Updated dependencies [35ad101]
1273
+ - Updated dependencies [ceb33a9]
1274
+ - Updated dependencies [dccbcec]
1275
+ - Updated dependencies [05bc692]
1276
+ - Updated dependencies [73d9795]
1277
+ - Updated dependencies [8012960]
1278
+ - Updated dependencies [266654d]
1279
+ - Updated dependencies [f34f56b]
1280
+ - Updated dependencies [f399618]
1281
+ - Updated dependencies [75e9301]
1282
+ - Updated dependencies [f334d66]
1283
+ - Updated dependencies [2810695]
1284
+ - @objectstack/platform-objects@17.2.0
1285
+ - @objectstack/spec@17.2.0
1286
+ - @objectstack/core@17.2.0
1287
+ - @objectstack/metadata-core@17.2.0
1288
+ - @objectstack/types@17.2.0
1289
+ - @objectstack/formula@17.2.0
1290
+
3
1291
  ## 17.1.0
4
1292
 
5
1293
  ### Minor Changes