@object-ui/core 17.5.0 → 17.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +7404 -0
  2. package/README.md +127 -26
  3. package/dist/actions/ActionEngine.d.ts +5 -3
  4. package/dist/actions/ActionEngine.js +14 -10
  5. package/dist/actions/ActionRunner.d.ts +363 -30
  6. package/dist/actions/ActionRunner.js +544 -58
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/actionKeys.d.ts +75 -13
  9. package/dist/actions/actionKeys.js +131 -16
  10. package/dist/actions/actionResponse.js +3 -3
  11. package/dist/actions/index.d.ts +2 -0
  12. package/dist/actions/index.js +2 -0
  13. package/dist/actions/recordDelete.d.ts +118 -0
  14. package/dist/actions/recordDelete.js +112 -0
  15. package/dist/actions/recordIdParam.d.ts +77 -0
  16. package/dist/actions/recordIdParam.js +37 -0
  17. package/dist/actions/serverActionHandler.d.ts +1 -1
  18. package/dist/actions/serverActionHandler.js +1 -1
  19. package/dist/adapters/ValueDataSource.d.ts +6 -1
  20. package/dist/adapters/ValueDataSource.js +1090 -98
  21. package/dist/adapters/batchTransaction.d.ts +1 -1
  22. package/dist/adapters/batchTransaction.js +1 -1
  23. package/dist/builder/schema-builder.d.ts +38 -81
  24. package/dist/builder/schema-builder.js +33 -152
  25. package/dist/data-scope/ViewDataProvider.d.ts +1 -1
  26. package/dist/data-scope/ViewDataProvider.js +21 -1
  27. package/dist/data-scope/element-data-source.d.ts +261 -7
  28. package/dist/data-scope/element-data-source.js +342 -52
  29. package/dist/data-scope/index.d.ts +9 -4
  30. package/dist/data-scope/index.js +9 -4
  31. package/dist/evaluator/ExpressionEvaluator.d.ts +128 -1
  32. package/dist/evaluator/ExpressionEvaluator.js +226 -13
  33. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  34. package/dist/evaluator/FormulaFunctions.js +166 -27
  35. package/dist/evaluator/SafeExpressionParser.js +0 -4
  36. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  37. package/dist/evaluator/declaredPredicate.js +118 -17
  38. package/dist/evaluator/fieldRules.d.ts +107 -4
  39. package/dist/evaluator/fieldRules.js +231 -35
  40. package/dist/evaluator/index.d.ts +2 -0
  41. package/dist/evaluator/index.js +2 -0
  42. package/dist/evaluator/listConditional.d.ts +125 -9
  43. package/dist/evaluator/listConditional.js +167 -26
  44. package/dist/evaluator/optionRules.d.ts +126 -0
  45. package/dist/evaluator/optionRules.js +141 -4
  46. package/dist/evaluator/predicateInput.d.ts +15 -4
  47. package/dist/evaluator/predicateInput.js +35 -2
  48. package/dist/evaluator/rowPredicateCanon.d.ts +56 -0
  49. package/dist/evaluator/rowPredicateCanon.js +158 -0
  50. package/dist/evaluator/subjectPermissions.d.ts +76 -0
  51. package/dist/evaluator/subjectPermissions.js +44 -0
  52. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  53. package/dist/evaluator/unevaluablePredicate.js +70 -0
  54. package/dist/index.d.ts +18 -0
  55. package/dist/index.js +97 -2
  56. package/dist/protocols/index.d.ts +0 -1
  57. package/dist/protocols/index.js +9 -1
  58. package/dist/registry/Registry.d.ts +397 -48
  59. package/dist/registry/Registry.js +263 -11
  60. package/dist/registry/WidgetRegistry.js +7 -3
  61. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  62. package/dist/registry/html-tier-intrinsics.js +110 -0
  63. package/dist/registry/public-blocks.d.ts +8 -0
  64. package/dist/registry/public-blocks.js +57 -0
  65. package/dist/theme/ThemeEngine.js +1 -1
  66. package/dist/theme/index.d.ts +1 -1
  67. package/dist/theme/index.js +1 -1
  68. package/dist/types/index.d.ts +92 -13
  69. package/dist/utils/chart-category-key.d.ts +82 -0
  70. package/dist/utils/chart-category-key.js +84 -0
  71. package/dist/utils/chart-measure-key.d.ts +64 -0
  72. package/dist/utils/chart-measure-key.js +65 -0
  73. package/dist/utils/chart-presentation.d.ts +188 -0
  74. package/dist/utils/chart-presentation.js +345 -0
  75. package/dist/utils/chart-series.d.ts +271 -31
  76. package/dist/utils/chart-series.js +478 -55
  77. package/dist/utils/column-identity.d.ts +43 -0
  78. package/dist/utils/column-identity.js +47 -1
  79. package/dist/utils/column-sortability.d.ts +157 -0
  80. package/dist/utils/column-sortability.js +217 -0
  81. package/dist/utils/dashboard-filters.d.ts +38 -6
  82. package/dist/utils/dashboard-filters.js +107 -61
  83. package/dist/utils/dataset-format.d.ts +211 -30
  84. package/dist/utils/dataset-format.js +407 -52
  85. package/dist/utils/date-display.d.ts +354 -0
  86. package/dist/utils/date-display.js +463 -0
  87. package/dist/utils/date-macros.js +7 -0
  88. package/dist/utils/debug.js +7 -0
  89. package/dist/utils/drill-down.d.ts +78 -0
  90. package/dist/utils/drill-down.js +93 -0
  91. package/dist/utils/emptiness.d.ts +96 -0
  92. package/dist/utils/emptiness.js +101 -0
  93. package/dist/utils/expand-fields.d.ts +171 -11
  94. package/dist/utils/expand-fields.js +171 -11
  95. package/dist/utils/extract-records.d.ts +65 -5
  96. package/dist/utils/extract-records.js +65 -8
  97. package/dist/utils/filter-converter.d.ts +152 -4
  98. package/dist/utils/filter-converter.js +1092 -22
  99. package/dist/utils/filter-tokens.d.ts +49 -5
  100. package/dist/utils/filter-tokens.js +105 -34
  101. package/dist/utils/freeze-schema.d.ts +38 -1
  102. package/dist/utils/freeze-schema.js +10 -0
  103. package/dist/utils/grouping-fields.d.ts +61 -0
  104. package/dist/utils/grouping-fields.js +84 -0
  105. package/dist/utils/humanize-label.d.ts +60 -0
  106. package/dist/utils/humanize-label.js +62 -0
  107. package/dist/utils/managedBy.d.ts +30 -5
  108. package/dist/utils/managedBy.js +5 -5
  109. package/dist/utils/native-date-value.d.ts +48 -0
  110. package/dist/utils/native-date-value.js +167 -0
  111. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  112. package/dist/utils/non-grid-row-ceiling.js +141 -0
  113. package/dist/utils/normalize-list-view.d.ts +131 -1
  114. package/dist/utils/normalize-list-view.js +248 -6
  115. package/dist/utils/number-display.d.ts +70 -17
  116. package/dist/utils/number-display.js +34 -12
  117. package/dist/utils/object-aggregate-query.d.ts +113 -0
  118. package/dist/utils/object-aggregate-query.js +50 -0
  119. package/dist/utils/parent-scope.d.ts +123 -0
  120. package/dist/utils/parent-scope.js +140 -0
  121. package/dist/utils/predicate-fields.d.ts +62 -4
  122. package/dist/utils/predicate-fields.js +169 -4
  123. package/dist/utils/record-color.d.ts +75 -0
  124. package/dist/utils/record-color.js +65 -0
  125. package/dist/utils/record-source.d.ts +298 -0
  126. package/dist/utils/record-source.js +331 -0
  127. package/dist/utils/record-title.d.ts +148 -8
  128. package/dist/utils/record-title.js +220 -26
  129. package/dist/utils/reference-keys.d.ts +21 -22
  130. package/dist/utils/reference-keys.js +532 -29
  131. package/dist/utils/retired-field-types.d.ts +90 -0
  132. package/dist/utils/retired-field-types.js +124 -0
  133. package/dist/utils/sort-query.d.ts +128 -26
  134. package/dist/utils/sort-query.js +240 -13
  135. package/dist/utils/text-comparand.d.ts +92 -0
  136. package/dist/utils/text-comparand.js +99 -0
  137. package/dist/utils/unmaterialized-fields.d.ts +52 -0
  138. package/dist/utils/unmaterialized-fields.js +57 -0
  139. package/dist/utils/without-denied-fields.d.ts +49 -0
  140. package/dist/utils/without-denied-fields.js +52 -0
  141. package/dist/validation/index.d.ts +9 -4
  142. package/dist/validation/index.js +9 -4
  143. package/dist/validation/schema-validator.d.ts +1 -1
  144. package/dist/validation/schema-validator.js +305 -82
  145. package/dist/validation/server-owned-value.d.ts +62 -0
  146. package/dist/validation/server-owned-value.js +99 -0
  147. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  148. package/dist/validation/validators/object-validation-engine.js +14 -5
  149. package/package.json +8 -9
  150. package/dist/__benchmarks__/core.bench.d.ts +0 -8
  151. package/dist/__benchmarks__/core.bench.js +0 -53
  152. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  153. package/dist/data-scope/DataScopeManager.js +0 -229
  154. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  155. package/dist/protocols/ResponsiveProtocol.js +0 -158
  156. package/dist/validation/validation-engine.d.ts +0 -88
  157. package/dist/validation/validation-engine.js +0 -434
@@ -5,10 +5,52 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
9
- import { hasDeclaredPredicate } from '../evaluator/declaredPredicate';
10
- import { globalUndoManager } from './UndoManager';
11
- import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys';
8
+ import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator.js';
9
+ import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';
10
+ import { globalUndoManager } from './UndoManager.js';
11
+ import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys.js';
12
+ import { readActionPayload } from './actionResponse.js';
13
+ import { toPredicateRecord } from '../utils/predicate-record.js';
14
+ /**
15
+ * The text the runner writes itself, and so the only text
16
+ * {@link ActionRunner.setTranslator}'s translator is asked for. In each entry
17
+ * `key` names the locale-pack entry (`@object-ui/i18n`'s packs define it) and
18
+ * `defaultValue` is the English source, which is also what shows when no
19
+ * translator is installed.
20
+ *
21
+ * - `completedSuccessfully` — the success toast when the author supplied no
22
+ * copy for it: no `outcomeMessages` entry for the answer's `outcome` and no
23
+ * `successMessage` (objectui#10900, objectui#11344).
24
+ * - `failed` — the error toast when the error that reached the toast carries
25
+ * no readable message, and a parallel chain's result when its last action
26
+ * rejected (objectui#10969).
27
+ * - `parallelFailed` — a parallel chain's error when no failed action reported
28
+ * one of its own (objectui#10969).
29
+ * - `undo` — the label the runner hands the toast handler for an undoable
30
+ * success toast's Undo affordance (objectui#10969).
31
+ *
32
+ * An author's `outcomeMessages` / `successMessage` / `errorMessage` and an
33
+ * action's own error are never in this table: the host localizes authored copy
34
+ * before dispatch, and the runner only fills in its `${result.*}` tokens.
35
+ */
36
+ const RUNNER_TEXT = {
37
+ completedSuccessfully: {
38
+ key: 'actions.completedSuccessfully',
39
+ defaultValue: 'Action completed successfully',
40
+ },
41
+ failed: {
42
+ key: 'actions.failed',
43
+ defaultValue: 'Action failed',
44
+ },
45
+ parallelFailed: {
46
+ key: 'actions.parallelFailed',
47
+ defaultValue: 'One or more parallel actions failed',
48
+ },
49
+ undo: {
50
+ key: 'actions.undo',
51
+ defaultValue: 'Undo',
52
+ },
53
+ };
12
54
  /**
13
55
  * Bind the server-CEL-parity `os.user` identity alias into an action context
14
56
  * (#2358 trap 1). `@objectstack/spec` documents the canonical CEL identity
@@ -79,6 +121,24 @@ function readContextObjectName(context) {
79
121
  const name = context.objectName;
80
122
  return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined;
81
123
  }
124
+ /**
125
+ * The field definitions the host published for `objectName`, or `undefined`
126
+ * (objectui#11122).
127
+ *
128
+ * A host publishes `objectFields` BESIDE `objectName` (`useConsoleActionRuntime`
129
+ * and `RecordDetailView` both do), so the pair names one object and its fields.
130
+ * They are answered only for THAT object: an action that retargets another
131
+ * object (a related-list row on the record page carries the child's
132
+ * `objectName`) gets no field map rather than the host object's, because
133
+ * reading one object's field types against another object's row would decide
134
+ * which of its fields are relations by the wrong schema.
135
+ */
136
+ function readContextObjectFields(context, objectName) {
137
+ if (readContextObjectName(context) !== objectName)
138
+ return undefined;
139
+ const fields = context.objectFields;
140
+ return fields && typeof fields === 'object' ? fields : undefined;
141
+ }
82
142
  /**
83
143
  * Whether opening FormView `viewName` from a record of `contextObject` would
84
144
  * cross an object boundary (objectui#4292).
@@ -101,6 +161,91 @@ function crossesObjectBoundary(viewName, contextObject) {
101
161
  return false;
102
162
  return !viewName.startsWith(`${contextObject}.`);
103
163
  }
164
+ /**
165
+ * Is this the declarative single-record field write?
166
+ *
167
+ * Asks the AUTHORED key, not `type`: the spec materializes `type: 'script'`
168
+ * here and refuses every other explicit spelling, so `type` cannot answer the
169
+ * question — see the dispatch comment in {@link ActionRunner.execute}.
170
+ *
171
+ * `'update'` is the only member the spec declares (`'delete'` and `'custom'`
172
+ * have no row-level form), and an unrecognized spelling is a parse rejection
173
+ * upstream. This reads the ONE member rather than "any truthy `operation`" so a
174
+ * future member cannot silently inherit the update write path — the dispatch
175
+ * table's own guard reasoning, applied to this key.
176
+ */
177
+ function isUpdateOperationAction(action) {
178
+ return action.operation === 'update';
179
+ }
180
+ /**
181
+ * The fields an `operation: 'update'` action writes, and their prior values.
182
+ *
183
+ * `undoable` promises to restore EXACTLY the fields the action wrote, so the
184
+ * captured set is the effective payload — `patch` merged under the collected
185
+ * `params` — and never `patch` alone: a param-collected field left out of the
186
+ * capture would make Undo restore SOME of the edit and report a full one, which
187
+ * is worse than offering no Undo at all.
188
+ *
189
+ * Prior values come from the row record the invoking surface stashed under
190
+ * `params._rowRecord` (the same client-side stash the record-id dance reads).
191
+ * A field the row carries as `null` is captured as `null`: it was empty, and
192
+ * clearing it back to empty is what restoring it means on the data plane.
193
+ *
194
+ * ⛔ A field the row does not CARRY is never captured as `null`
195
+ * (objectui#10404). Absent is not empty: a list row is projected by `$select`,
196
+ * and a written field no column shows is missing from the row while the server
197
+ * holds a real value for it. Recording `null` there made Undo write `null` over
198
+ * the value it existed to restore. The list harvest (`listViewPredicates`) now
199
+ * asks for the written fields of an `undoable` action, so a projected row
200
+ * carries them; this is the backstop for the ones it cannot ask for (a field
201
+ * the object does not declare or the principal may not read, a row no harvest
202
+ * shaped). "Carries" means an own key whose value is not `undefined`: JSON
203
+ * cannot send `undefined`, so such a value says nothing about what is stored.
204
+ *
205
+ * Answers `undefined` when any written field is not carried, and the caller
206
+ * then offers no Undo at all. Capturing only the carried fields is not an
207
+ * option, for the reason above: a partial restore reported as a full one is
208
+ * worse than no Undo.
209
+ *
210
+ * ⛔ A relation's prior value is its STORED id, never the record `$expand`
211
+ * put in its place (objectui#11122). Surfaces read rows with `$expand` on the
212
+ * relations they show (a grid, its visible and grouping columns; the record
213
+ * page, every relation the reader may read), and the server replaces the id
214
+ * in place with the related record. Copied verbatim, that record became the
215
+ * Undo value and Undo wrote it into the reference slot, which stores an id:
216
+ * refused under a strict value-shape posture, stored as corruption under the
217
+ * lenient one. So each carried value goes through `toPredicateRecord`, the one
218
+ * rule that binds a fetched record the way the server stores it: a field the
219
+ * object DECLARES relational (`EXPANDABLE_FIELD_TYPES`, the set that decides
220
+ * what is expanded in the first place) collapses to its id, element-wise for a
221
+ * `multiple` relation, and every other field is left exactly as the row
222
+ * carries it. It is read from `fields`, never from the value's shape: a `json`
223
+ * field may hold an object with an `id`, and that object is its stored value.
224
+ *
225
+ * `fields` is the written object's field definitions (`objectSchema.fields`,
226
+ * either served shape), and it is REQUIRED so that a caller without them has
227
+ * to say so: `undefined` collapses nothing, because a caller that cannot name
228
+ * the relations must not guess them.
229
+ *
230
+ * Exported (objectui#11082) because it is THE rule, not this runner's: every
231
+ * surface that builds an `update` Undo snapshot itself calls it rather than
232
+ * restating it. Today that is the console runtime's `api` handler
233
+ * (`useConsoleActionRuntime`) and the record page's own `api` handler
234
+ * (`RecordDetailView`), which reads prior values off the page's loaded record;
235
+ * that record lacks a written field when field-level security hides it from
236
+ * the reader. To name the fields that blocked a capture, ask per field:
237
+ * `captureUpdateUndoData([field], rowRecord, fields) === undefined`.
238
+ */
239
+ export function captureUpdateUndoData(writtenFields, rowRecord, fields) {
240
+ if (!writtenFields.every((field) => rowCarries(rowRecord, field)))
241
+ return undefined;
242
+ const stored = toPredicateRecord(rowRecord, fields);
243
+ return Object.fromEntries(writtenFields.map((field) => [field, stored[field]]));
244
+ }
245
+ /** Whether the row CARRIES `field`: an own key whose value is not `undefined`. */
246
+ function rowCarries(rowRecord, field) {
247
+ return Object.prototype.hasOwnProperty.call(rowRecord, field) && rowRecord[field] !== undefined;
248
+ }
104
249
  /*
105
250
  * ## The two predicate gates in `execute`, and the one question they ask first
106
251
  *
@@ -128,8 +273,10 @@ function crossesObjectBoundary(viewName, contextObject) {
128
273
  * Both read {@link hasDeclaredPredicate} — the repo's one definition of that
129
274
  * question, in `evaluator/declaredPredicate.ts` beside the normalizer it is
130
275
  * derived from, with the scope objectui#3850 ruled on (`''` / whitespace-only /
131
- * empty-`source` envelope / non-predicate junk are NOT declared; a declared
132
- * `false` IS). This gate arrived here first as a module-private helper with a
276
+ * empty-`source` envelope are NOT declared; a declared `false` IS) and the
277
+ * third state objectui#11358 added (a present value with no evaluable `source`
278
+ * — `0`, `{}`, an array, an `ast`-only envelope — IS declared, and faults).
279
+ * This gate arrived here first as a module-private helper with a
133
280
  * scope note saying it stayed private until that ruling landed; it has, so the
134
281
  * definition moved down a layer and the renderer side reads the same one
135
282
  * (`components/renderers/action/visibility-gate.ts` re-exports it as
@@ -163,9 +310,15 @@ function crossesObjectBoundary(viewName, contextObject) {
163
310
  * historical `Boolean(raw)` coercion, so `0` there meant "hidden" — fail-CLOSED
164
311
  * on junk, the opposite of what this module committed to for `disabled`
165
312
  * (`catch { isDisabled = false }`). That filter now reads
166
- * {@link hasDeclaredPredicate} too (objectui#3957), so a value that is not a
167
- * predicate at all decides nothing on either key, at either entry: `0` / `{}` are
168
- * "no gate" here, in the engine filter, and in the shared definition.
313
+ * {@link hasDeclaredPredicate} too (objectui#3957), so the two entries answer
314
+ * a value that is not a predicate at all the same way. Since objectui#11358
315
+ * that answer is "declared, and faulting" rather than "no gate": `0` / `{}` /
316
+ * an `ast`-only envelope reach `evaluateCondition`, which reports them and
317
+ * answers its fail-soft `true` — so `disabled` refuses the action (its closed
318
+ * direction, as for any faulting `disabled`) and `condition` lets it run (the
319
+ * direction a faulting `condition` has always taken), and the engine filter
320
+ * hides it (`throwOnError`). Pinned in `ActionRunner.disabledGate.test.ts` /
321
+ * `ActionRunner.conditionGate.test.ts`.
169
322
  */
170
323
  export class ActionRunner {
171
324
  constructor(context = {}) {
@@ -229,6 +382,12 @@ export class ActionRunner {
229
382
  writable: true,
230
383
  value: void 0
231
384
  });
385
+ Object.defineProperty(this, "translate", {
386
+ enumerable: true,
387
+ configurable: true,
388
+ writable: true,
389
+ value: void 0
390
+ });
232
391
  /**
233
392
  * Built-in dispatch, one entry per runnable action type.
234
393
  *
@@ -266,6 +425,21 @@ export class ActionRunner {
266
425
  this.navigationHandler = null;
267
426
  this.paramCollectionHandler = null;
268
427
  this.resultDialogHandler = null;
428
+ this.translate = null;
429
+ }
430
+ /**
431
+ * Set the translator for the text the runner supplies itself — the host's
432
+ * `t`, injected so this package takes no i18n dependency. That text is the
433
+ * `RUNNER_TEXT` table in this file: the generic success toast shown when an
434
+ * action declares neither an `outcomeMessages` entry for the answer nor a
435
+ * `successMessage`, the error fallbacks when no readable error message
436
+ * reached the runner, and the Undo label of an undoable success toast. An
437
+ * author's `outcomeMessages` / `successMessage` / `errorMessage` and an
438
+ * action's own error never go through this translator: the host localizes
439
+ * authored copy before dispatch. With no translator the text stays English.
440
+ */
441
+ setTranslator(translate) {
442
+ this.translate = translate;
269
443
  }
270
444
  /**
271
445
  * Set a custom confirmation handler (e.g., Shadcn AlertDialog).
@@ -328,10 +502,10 @@ export class ActionRunner {
328
502
  try {
329
503
  // `ActionDef` accepts any key of any type, so a typo (`targt`) and a
330
504
  // retired key (`execute`) both reach here having type-checked. Neither
331
- // binds a handler, and binding no handler silently is the #2169 "Mark Done
332
- // does nothing" shape. Dev-only, warn-once, changes nothing (#4075 step 1).
505
+ // binds a handler, and binding no handler silently is the objectstack-ai/objectstack#2169 "Mark Done
506
+ // does nothing" shape. Dev-only, warn-once, changes nothing (objectstack-ai/objectstack#4075 step 1).
333
507
  warnOnUnknownActionKeys(action);
334
- // The compat window for the object-form `params` payload (#5777, maintainer
508
+ // The compat window for the object-form `params` payload (objectstack-ai/objectstack#5777, maintainer
335
509
  // ruling 2026-08-06 direction A). Checked HERE, before the param-collection
336
510
  // block below rewrites `action.params` into a values map — after that point
337
511
  // an action that authored a legitimate ActionParam[] DEFINITION array also
@@ -375,9 +549,16 @@ export class ActionRunner {
375
549
  // code only evaluated the STRING form and treated any object as truthy,
376
550
  // so an envelope-disabled action was ALWAYS "disabled" — silently
377
551
  // blocking every execution (param dialog never opened, handler never
378
- // ran). `evaluateCondition` already handles boolean/string/envelope;
379
- // and the renderers are authoritative for the visual disabled state, so
380
- // any eval failure here defaults to NOT-disabled (don't false-block).
552
+ // ran). `evaluateCondition` already handles boolean/string/envelope.
553
+ //
554
+ // A predicate that FAULTS — an unbound root, a misspelled field,
555
+ // `current_user.can(…)` before the permissions payload has loaded — does
556
+ // NOT reach the `catch` below: `evaluateCondition` handles its own
557
+ // faults and answers its fail-soft `true`, which on this key means
558
+ // DISABLED, so the action is refused ("Action is disabled"). That is the
559
+ // closed direction every `disabled` leg takes (objectui#11212,
560
+ // objectui#11242). The `catch` only covers a throw from outside that
561
+ // fault handling, and leaves such an action not disabled.
381
562
  let isDisabled = false;
382
563
  try {
383
564
  isDisabled = this.evaluator.evaluateCondition(action.disabled);
@@ -389,14 +570,13 @@ export class ActionRunner {
389
570
  return { success: false, error: 'Action is disabled' };
390
571
  }
391
572
  }
392
- // Confirmation (structured or simple)
393
- const confirmMessage = action.confirm?.message || action.confirmText;
394
- if (confirmMessage) {
395
- const confirmed = await this.confirmHandler(this.evaluator.evaluate(confirmMessage), action.confirm ? {
396
- title: action.confirm.title,
397
- confirmText: action.confirm.confirmText,
398
- cancelText: action.confirm.cancelText,
399
- } : undefined);
573
+ // Confirmation. One spelling: `confirmText` — the only one the
574
+ // translation bundle can address (`…._actions.{name}.confirmText`). The
575
+ // structured `confirm.message` read that used to OUTRANK it is retired
576
+ // (objectui#4314): it had zero producers and no bundle key, so a dialog
577
+ // authored that way silently lost localization.
578
+ if (action.confirmText) {
579
+ const confirmed = await this.confirmHandler(this.evaluator.evaluate(action.confirmText));
400
580
  if (!confirmed) {
401
581
  return { success: false, error: 'Action cancelled by user' };
402
582
  }
@@ -425,6 +605,32 @@ export class ActionRunner {
425
605
  action.params._rowRecord = rowRecord;
426
606
  }
427
607
  }
608
+ // ── `operation` is read BEFORE `type`, and this is the only order that
609
+ // can work ────────────────────────────────────────────────────────────
610
+ //
611
+ // `operation: 'update'` is a PARALLEL key beside `type`, not a member of
612
+ // it: `@objectstack/spec` materializes `type: 'script'` on such an action
613
+ // (the platform action route is the script type's own route) and refuses
614
+ // any other explicit `type`. `ActionType` therefore did not gain a
615
+ // member, which is why `builtinExecutors` — a `Record<RunnableActionType,
616
+ // …>` — still compiles unchanged across the bump that added this key.
617
+ //
618
+ // The cost of that design is paid here: by the time `actionType` is
619
+ // consulted, an update action is indistinguishable from every other
620
+ // script action. Dispatched on `type` it would reach the registered
621
+ // `script` handler with its `patch` never merged into the request — the
622
+ // route would be POSTed a payload with no field values in it and answer a
623
+ // green success for having written nothing (objectui#2960). Worse, a row
624
+ // rehydrated from `sys_metadata` UNPARSED (objectui#3903) carries no
625
+ // materialized `type` at all, so `actionType` falls back to the action's
626
+ // NAME, matches no executor, and the action lands in
627
+ // `executeActionSchema`. Reading `operation` first is what makes both of
628
+ // those impossible.
629
+ if (isUpdateOperationAction(action)) {
630
+ const result = await this.executeUpdateOperation(action);
631
+ await this.handlePostExecution(action, result);
632
+ return result;
633
+ }
428
634
  // Check for a registered custom handler first
429
635
  if (actionType && this.handlers.has(actionType)) {
430
636
  const handler = this.handlers.get(actionType);
@@ -477,13 +683,13 @@ export class ActionRunner {
477
683
  const firstFail = results.find(r => r.status === 'fulfilled' && !r.value.success);
478
684
  return {
479
685
  success: false,
480
- error: firstFail?.value?.error || 'One or more parallel actions failed',
686
+ error: firstFail?.value?.error || this.runnerText(RUNNER_TEXT.parallelFailed),
481
687
  };
482
688
  }
483
689
  const lastResult = results[results.length - 1];
484
690
  return lastResult.status === 'fulfilled'
485
691
  ? lastResult.value
486
- : { success: false, error: 'Action failed' };
692
+ : { success: false, error: this.runnerText(RUNNER_TEXT.failed) };
487
693
  }
488
694
  // Sequential execution — stop on first failure
489
695
  let lastResult = { success: true };
@@ -495,6 +701,61 @@ export class ActionRunner {
495
701
  }
496
702
  return lastResult;
497
703
  }
704
+ /**
705
+ * One entry of `RUNNER_TEXT`, in the installed translator's language. An
706
+ * empty answer from the translator falls back to the English source rather
707
+ * than raising an empty toast or an empty label.
708
+ */
709
+ runnerText({ key, defaultValue }) {
710
+ return this.translate?.(key, { defaultValue }) || defaultValue;
711
+ }
712
+ /**
713
+ * The success toast's text, by the contract's three rungs
714
+ * (`ActionSchema.outcomeMessages`, objectui#11344):
715
+ *
716
+ * 1. `outcomeMessages[outcome]`, where `outcome` is the top-level `outcome`
717
+ * of the handler's return value — the payload `${result.*}` reads, so it
718
+ * is the same fact an author writes as `${result.outcome}`;
719
+ * 2. `successMessage`;
720
+ * 3. the runner's own default text, in its translator's language.
721
+ *
722
+ * Rungs 1 and 2 are the author's copy. Both arrive already localized — the
723
+ * host's localizer resolves the bundle entry or the inline `I18nLabel` map
724
+ * before dispatch — and both have their `${result.*}` tokens filled in here,
725
+ * through the one token grammar and scope `onSuccess.navigate` reads
726
+ * (`substituteScopeTokens` over `readActionPayload(result.data)`). Only the
727
+ * sink differs: a toast is text, so a value is inserted as-is, where a URL
728
+ * position percent-encodes it.
729
+ *
730
+ * ⛔ `result.data.message` is not a rung. It used to outrank both rungs of
731
+ * author copy, which put whatever language the server happened to write in
732
+ * front of the user's locale; the contract names no server-message rung, and
733
+ * an author who wants the server's sentence can still write
734
+ * `${result.message}` as the copy.
735
+ *
736
+ * A rung counts only when it is a non-empty string. An `I18nLabel` map that
737
+ * reached the runner without passing through a localizer has no language to
738
+ * resolve against here, so it is skipped rather than handed to the toast as
739
+ * an object — the React #31 crash the error branch below guards against.
740
+ */
741
+ composeSuccessMessage(action, result) {
742
+ const payload = readActionPayload(result.data);
743
+ const outcome = payload && typeof payload === 'object'
744
+ ? payload.outcome
745
+ : undefined;
746
+ const outcomeMessages = action.outcomeMessages;
747
+ const outcomeCopy = typeof outcome === 'string'
748
+ && outcomeMessages && typeof outcomeMessages === 'object'
749
+ && Object.prototype.hasOwnProperty.call(outcomeMessages, outcome)
750
+ ? outcomeMessages[outcome]
751
+ : undefined;
752
+ for (const copy of [outcomeCopy, action.successMessage]) {
753
+ if (typeof copy === 'string' && copy.trim() !== '') {
754
+ return substituteScopeTokens(copy, { result: payload }, (value) => value);
755
+ }
756
+ }
757
+ return this.runnerText(RUNNER_TEXT.completedSuccessfully);
758
+ }
498
759
  /**
499
760
  * Post-execution: emit toast notifications, handle chaining, callbacks.
500
761
  */
@@ -510,27 +771,25 @@ export class ActionRunner {
510
771
  const showToast = action.toast ?? { showOnSuccess: true, showOnError: true };
511
772
  const duration = action.toast?.duration;
512
773
  if (result.success && !hasResultDialog && !result.silent && showToast.showOnSuccess !== false) {
513
- // Prefer a DYNAMIC message the server returned (result.data.message)
514
- // over the static action.successMessage. Server-driven actions like
515
- // check_app_updates / publish / install compute a real outcome
516
- // ("2 app updates available: CRM 1.0.0→1.0.1", "Published v1.2.0")
517
- // that the static label can't express; without this the user only ever
518
- // sees a generic "Done". Falls back to the static label, then a default.
519
- const dyn = (result.data && typeof result.data === 'object'
520
- && typeof result.data.message === 'string')
521
- ? String(result.data.message).trim()
522
- : '';
523
- const message = dyn || action.successMessage || 'Action completed successfully';
774
+ // The server returns FACTS and the console writes the sentence
775
+ // (ruling A on objectstack-ai/cloud#2315) — see `composeSuccessMessage`.
776
+ const message = this.composeSuccessMessage(action, result);
524
777
  // Undoable action: register the captured operation on the global
525
778
  // UndoManager and surface an "Undo" affordance on the toast (the
526
- // consumer's toast handler wires the button to UndoManager).
779
+ // consumer's toast handler wires the button to UndoManager). The
780
+ // button's label is the runner's own text, so it is translated here
781
+ // rather than left to each handler's English default (objectui#10969).
527
782
  if (result.undo) {
528
783
  try {
529
784
  globalUndoManager.push(result.undo);
530
785
  }
531
786
  catch { /* non-fatal */ }
532
787
  }
533
- this.toastHandler(message, { type: 'success', duration, undo: result.undo ? {} : undefined });
788
+ this.toastHandler(message, {
789
+ type: 'success',
790
+ duration,
791
+ undo: result.undo ? { label: this.runnerText(RUNNER_TEXT.undo) } : undefined,
792
+ });
534
793
  }
535
794
  if (!result.success && showToast.showOnError !== false && result.error) {
536
795
  // `result.error` is typed as a string, but a handler bug can leak an
@@ -543,7 +802,7 @@ export class ActionRunner {
543
802
  ? raw
544
803
  : (raw && typeof raw.message === 'string')
545
804
  ? raw.message
546
- : 'Action failed';
805
+ : this.runnerText(RUNNER_TEXT.failed);
547
806
  this.toastHandler(message, { type: 'error', duration });
548
807
  }
549
808
  }
@@ -558,12 +817,10 @@ export class ActionRunner {
558
817
  catch (err) {
559
818
  // Acknowledgement failure is non-fatal; the underlying action already
560
819
  // succeeded. Log so consumers can wire it through their error reporter.
561
- // eslint-disable-next-line no-console
562
820
  console.warn('[ActionRunner] resultDialog handler rejected; treating as acknowledged', err);
563
821
  }
564
822
  }
565
823
  else {
566
- // eslint-disable-next-line no-console
567
824
  console.warn('[ActionRunner] action.resultDialog set but no resultDialogHandler registered — the response value will not be shown to the user.', { action: action.name, data: result.data });
568
825
  }
569
826
  }
@@ -586,25 +843,204 @@ export class ActionRunner {
586
843
  if (chainResult.reload)
587
844
  result.reload = true;
588
845
  }
589
- // Execute onSuccess/onFailure callbacks
846
+ // ── ActionSchema.onSuccess — post-success navigation ────────────────────
847
+ //
848
+ // `053fdc8f9`, the console half of objectstack#9566/#9474. The spec
849
+ // declares `onSuccess` as a CLOSED STRICT object
850
+ // `{ navigate: string, openIn: 'self' | 'newTab' }`, refine-scoped to
851
+ // `type: 'api'` and `type: 'script'` — the two types whose success event
852
+ // carries a server response for `${result.*}` to read.
853
+ //
854
+ // That declared meaning is the key's ONLY meaning. The runner's older
855
+ // chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
856
+ // dispatched through `executeChain`) was retired by objectui#5934
857
+ // (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
858
+ // at parse, so no validated metadata could ever reach it, and the census
859
+ // found zero producers outside the channel's own pins.
860
+ //
861
+ // `readOnSuccessNavigation` stays as the shape guard, not as a
862
+ // discriminator: stored rows are rehydrated UNPARSED (objectstack-ai/objectstack#3903), so the value
863
+ // is still read as data, and a shape the spec refuses gets no reading —
864
+ // no navigation, no callback dispatch, no lenient fallback.
590
865
  if (result.success && action.onSuccess) {
591
- const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
592
- await this.executeChain(callbacks, 'sequential');
866
+ const navigation = readOnSuccessNavigation(action.onSuccess);
867
+ if (navigation) {
868
+ this.navigateOnSuccess(navigation, action, result);
869
+ }
593
870
  }
594
871
  if (!result.success && action.onFailure) {
595
872
  const callbacks = Array.isArray(action.onFailure) ? action.onFailure : [action.onFailure];
596
873
  await this.executeChain(callbacks, 'sequential');
597
874
  }
598
875
  }
876
+ /**
877
+ * Perform the `ActionSchema.onSuccess` hop for an action that just succeeded.
878
+ *
879
+ * **The router is the app's own.** `navigationHandler` is the seam every
880
+ * navigator in this file already goes through, and the console wires it to
881
+ * react-router's `navigate` — so `openIn: 'self'` is a real SPA route hop,
882
+ * in place, immune to popup blocking. No `window.location` / `window.open`
883
+ * mechanism is introduced here; the no-handler fallback is the same
884
+ * `result.redirect` hand-off `navigateTo` already uses.
885
+ *
886
+ * **No default is written here.** `openIn` is a materialised
887
+ * `.default('self')` in the spec, so parse output always carries a resolved
888
+ * member; this reads the ONE member that changes the branch and leaves the
889
+ * other implicit. A `?? 'self'` would be a second source of truth for a
890
+ * default the producer already resolved.
891
+ *
892
+ * **The two `openIn` spellings never cross.** This reads
893
+ * `onSuccess.openIn` (`'self' | 'newTab'`) and nothing else — never the
894
+ * top-level `type: 'url'` switch, which spells its new-tab member
895
+ * `'new-tab'`. Spec refuses each crossover spelling with a keyed error; a
896
+ * renderer that accepted either would be a second, looser contract than the
897
+ * one authors are validated against.
898
+ */
899
+ navigateOnSuccess(block, action, result) {
900
+ // `${result.*}` reads the HANDLER's return value, one level below the
901
+ // action envelope — the same level `readActionPayload` hands the
902
+ // `redirectUrl` convention. Reading `result.data` raw would see the
903
+ // `{ success, data }` envelope on pre-objectstack#3962 servers, which is
904
+ // the "one level too shallow" bug (#2904) in a new place.
905
+ const url = this.interpolateTarget(block.navigate, action, readActionPayload(result.data));
906
+ // `navigate` is author-supplied metadata and lands in a URL position, so
907
+ // it goes through the same scheme guard as every other navigator here.
908
+ if (!this.isValidUrl(url)) {
909
+ console.warn('[ActionRunner] onSuccess.navigate resolved to a URL this runner refuses to open '
910
+ + '(only http://, https:// and relative URLs are allowed) — no navigation performed.', { action: action.name, navigate: block.navigate, resolved: url });
911
+ return;
912
+ }
913
+ const newTab = block.openIn === 'newTab';
914
+ // Same external-URL convention `navigateTo` hands the handler; `newTab`
915
+ // stays the declared choice alone, never `?? isExternal`.
916
+ const external = url.startsWith('http://') || url.startsWith('https://');
917
+ if (this.navigationHandler) {
918
+ this.navigationHandler(url, { external, newTab });
919
+ return;
920
+ }
921
+ if (newTab) {
922
+ window.open(url, '_blank', 'noopener,noreferrer');
923
+ }
924
+ else {
925
+ result.redirect = url;
926
+ }
927
+ }
928
+ /**
929
+ * `operation: 'update'` — the declarative single-record field write.
930
+ *
931
+ * ## The write is the ROUTE's, never this client's
932
+ *
933
+ * This method composes a request and hands it to the registered `script`
934
+ * dispatch; it does not touch a `DataSource`. That is a security boundary,
935
+ * not an implementation preference. `POST /api/v1/actions/{object}/{action}`
936
+ * runs the write AS THE CALLER — the caller's permissions, the object's hooks
937
+ * and its validations all fire as for a user edit — while a client-side
938
+ * `dataSource.update()` reaches the data plane having consulted none of them.
939
+ * The spec says the same thing from its end by refusing `target`, `body`,
940
+ * `method` and `bodyExtra` beside this key and pinning `type` to `'script'`:
941
+ * there is exactly one execution surface for an update action, and it is the
942
+ * route.
943
+ *
944
+ * Consequently, a consumer that has registered NO server-action dispatch gets
945
+ * a loud refusal naming the remedy. The two silent alternatives were both
946
+ * available and both wrong: falling through to `executeScript` ends at "No
947
+ * script provided" (an update action declares no `target` — the author is
948
+ * sent hunting for a field they were right not to write), and falling through
949
+ * to `executeActionSchema` reports on an action that never ran.
950
+ *
951
+ * ## `patch` rides UNDER the collected params
952
+ *
953
+ * The spec's words: static field values "merged UNDER the user-supplied
954
+ * `params` so a fixed value can be declared without exposing it in the
955
+ * dialog". So a collected param of the same name WINS — the author declared a
956
+ * default, and the user answered it.
957
+ */
958
+ async executeUpdateOperation(action) {
959
+ const dispatch = this.handlers.get('script');
960
+ if (!dispatch) {
961
+ return {
962
+ success: false,
963
+ error: 'An `operation: "update"` action is written by the platform action route, which this ' +
964
+ 'client runner cannot reach: no `script` handler is registered. The write is never ' +
965
+ 'performed client-side — a direct data-plane update bypasses the route\'s permission ' +
966
+ 'floor, the object\'s hooks and its validations. Register a handler that POSTs to ' +
967
+ '/api/v1/actions/{object}/{action} — build one with createServerActionHandler from ' +
968
+ '@object-ui/core.',
969
+ };
970
+ }
971
+ const collected = (action.params && !Array.isArray(action.params))
972
+ ? { ...action.params }
973
+ : {};
974
+ const patch = (action.patch && typeof action.patch === 'object' && !Array.isArray(action.patch))
975
+ ? { ...action.patch }
976
+ : {};
977
+ // `patch` UNDER the collected params — see the doc above.
978
+ const params = { ...patch, ...collected };
979
+ // The row stash and the record selector are transport mechanics, not
980
+ // written fields: the dispatch strips `_rowRecord` before POSTing and reads
981
+ // `recordId` as the record's address.
982
+ const rowRecord = (collected._rowRecord && typeof collected._rowRecord === 'object')
983
+ ? collected._rowRecord
984
+ : undefined;
985
+ const writtenFields = Object.keys(params).filter((k) => k !== '_rowRecord' && k !== 'recordId');
986
+ const result = await dispatch({ ...action, params }, this.context);
987
+ // A refusal is returned exactly as the dispatch reported it. The route's
988
+ // envelope rule (`interpretActionResponse`) has already turned a 4xx/5xx
989
+ // AND a 200-with-`success:false` business rejection into `error`, and
990
+ // `handlePostExecution` surfaces that as the failure toast. Re-labelling it
991
+ // here, or answering success because the POST itself completed, is the
992
+ // objectui#2960 shape this branch exists to prevent.
993
+ if (!result.success)
994
+ return result;
995
+ // Undo: prior values of exactly the fields this action wrote. Needs a row
996
+ // record to read them from — without one there is nothing to restore, so
997
+ // the affordance is correctly not offered rather than offered empty. The
998
+ // same holds when the row lacks a written field (objectui#10404): no Undo,
999
+ // never one that writes `null` over the stored value. A relation the row
1000
+ // carries expanded is captured as its stored id, read against the field
1001
+ // map the host published for this object (objectui#11122).
1002
+ if (action.undoable && rowRecord && writtenFields.length > 0) {
1003
+ const objectName = action.objectName || readContextObjectName(this.context);
1004
+ const recordId = collected.recordId ?? rowRecord.id;
1005
+ if (objectName && recordId != null) {
1006
+ const undoData = captureUpdateUndoData(writtenFields, rowRecord, readContextObjectFields(this.context, objectName));
1007
+ if (undoData) {
1008
+ result.undo = {
1009
+ id: `undo-${objectName}-${String(recordId)}-${Date.now()}`,
1010
+ type: 'update',
1011
+ objectName,
1012
+ recordId: String(recordId),
1013
+ timestamp: Date.now(),
1014
+ // objectui#11080 — no English verb here. The toast that reports the
1015
+ // Undo / Redo already says which one it is, from a pack key, so an
1016
+ // action that declared no label is named by the object it acted on.
1017
+ description: action.label || objectName,
1018
+ undoData,
1019
+ redoData: Object.fromEntries(writtenFields.map((k) => [k, params[k]])),
1020
+ };
1021
+ }
1022
+ else {
1023
+ // The success toast then carries no Undo button, which is how the
1024
+ // user learns this one cannot be undone; this names the cause for
1025
+ // the author.
1026
+ console.warn('[ActionRunner] `undoable` update succeeded but offers no Undo: the row it ran on does not '
1027
+ + 'carry every field it wrote, so their prior values are unknown and an Undo would overwrite '
1028
+ + 'stored data. A list row carries a written field when the object declares it and the '
1029
+ + 'principal may read it.', { action: action.name, missing: writtenFields.filter((k) => !rowCarries(rowRecord, k)) });
1030
+ }
1031
+ }
1032
+ }
1033
+ return result;
1034
+ }
599
1035
  /**
600
1036
  * Execute script action — evaluates client-side expression via ExpressionEvaluator.
601
1037
  * Supports ${} template expressions referencing data, record, user context.
602
1038
  */
603
1039
  async executeScript(action) {
604
1040
  // `target` is the only handler slot. The `execute` alias was removed in
605
- // @objectstack/spec 17 (#3855), which rejects an authored `execute` at parse
1041
+ // @objectstack/spec 17 (objectstack-ai/objectstack#3855), which rejects an authored `execute` at parse
606
1042
  // with the rename prescription — so parsed metadata cannot carry it and a
607
- // `target || execute` fallback could only ever evaluate to `target` (#3856).
1043
+ // `target || execute` fallback could only ever evaluate to `target` (objectstack-ai/objectstack#3856).
608
1044
  const script = action.target;
609
1045
  if (!script) {
610
1046
  // A spec `body` IS a script — this runner just cannot run one (see the
@@ -627,11 +1063,11 @@ export class ActionRunner {
627
1063
  // `ActionDef` no longer declares `execute` — nor any open index signature
628
1064
  // — so `tsc` now catches the retired key at every site that AUTHORS an
629
1065
  // action literal in code. That is the half of the problem the type can
630
- // reach. It is not this half: #3903 established that stored
1066
+ // reach. It is not this half: objectstack-ai/objectstack#3903 established that stored
631
1067
  // `sys_metadata` rows are rehydrated UNPARSED, so metadata written before
632
1068
  // spec 17 arrives here as a plain object the compiler never saw, still
633
1069
  // carrying `execute`. Declaring the key to make this read compile would
634
- // re-legitimize a tombstone (#3855 removed it; the spec keeps it only to
1070
+ // re-legitimize a tombstone (objectstack-ai/objectstack#3855 removed it; the spec keeps it only to
635
1071
  // reject it BY NAME), and deleting the branch would send those rows back
636
1072
  // to a bare "no script provided" — which reads as "you forgot a field" to
637
1073
  // an author who did write one, the objectstack#2169 shape.
@@ -953,7 +1389,7 @@ export class ActionRunner {
953
1389
  * old object-form `params` page — POSTed an empty body here.
954
1390
  *
955
1391
  * `params` contributes only in its DEPRECATED non-array form (the compat window
956
- * #5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
1392
+ * objectstack-ai/objectstack#5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
957
1393
  * parameter DEFINITION list, not a payload: it reaches this method unconsumed
958
1394
  * only when no `paramCollectionHandler` is mounted, and POSTing the definitions
959
1395
  * as the request body was never a payload any endpoint wanted. It now falls
@@ -1098,7 +1534,7 @@ export class ActionRunner {
1098
1534
  * param. Consumers can extend by stuffing extra keys under
1099
1535
  * `context.ctx = {...}` before calling `runner.execute()`.
1100
1536
  */
1101
- interpolateTarget(target, action) {
1537
+ interpolateTarget(target, action, resultScope) {
1102
1538
  if (typeof target !== 'string' || target.indexOf('${') === -1)
1103
1539
  return target;
1104
1540
  // ── The `${param.X}` scope is an INTERNAL runtime value bag, not an
@@ -1135,14 +1571,24 @@ export class ActionRunner {
1135
1571
  const params = (action.params && typeof action.params === 'object' && !Array.isArray(action.params))
1136
1572
  ? action.params
1137
1573
  : {};
1138
- const ctx = this.buildInterpolationContext();
1139
- return target.replace(/\$\{(param|ctx)\.([\w.]+)\}/g, (_match, scope, path) => {
1140
- const root = scope === 'param' ? params : ctx;
1141
- const value = readPath(root, path);
1142
- if (value == null)
1143
- return '';
1144
- return encodeURIComponent(String(value));
1145
- });
1574
+ // The scope MAP defines the vocabulary — the pattern is built from its
1575
+ // keys, so a scope that is not in play is not a scope this call recognizes.
1576
+ //
1577
+ // `result` (the handler's own return value) is passed by exactly one caller:
1578
+ // the `ActionSchema.onSuccess` post-success hop, where a response exists.
1579
+ // Every other caller — `executeUrl`, `executeAPI` — interpolates a target
1580
+ // BEFORE the request, so there is no result to read; admitting `${result.*}`
1581
+ // there would widen the authorable surface with nothing behind it and
1582
+ // silently blank the token instead of leaving the author's mistake visible.
1583
+ //
1584
+ // The success toast's copy is the one other caller of the grammar, through
1585
+ // `substituteScopeTokens` directly (`composeSuccessMessage`): `result` only,
1586
+ // because that is the one scope the contract declares for the copy, and no
1587
+ // encoding, because a toast is text.
1588
+ const scopes = { param: params, ctx: this.buildInterpolationContext() };
1589
+ if (resultScope !== undefined)
1590
+ scopes.result = resultScope;
1591
+ return substituteScopeTokens(target, scopes, encodeURIComponent);
1146
1592
  }
1147
1593
  /**
1148
1594
  * Assemble the `ctx` namespace exposed to target interpolation.
@@ -1207,6 +1653,46 @@ export async function executeAction(action, context = {}) {
1207
1653
  const runner = new ActionRunner(context);
1208
1654
  return await runner.execute(action);
1209
1655
  }
1656
+ /**
1657
+ * Is this `onSuccess` the spec's navigation block?
1658
+ *
1659
+ * The test IS the spec's declaration: a non-array object carrying a STRING
1660
+ * `navigate`. Stored rows are rehydrated UNPARSED (objectstack-ai/objectstack#3903), so the runner reads
1661
+ * the value as data and anything else gets NO reading — since objectui#5934
1662
+ * retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
1663
+ * there is no other channel for an off-contract shape to fall into. This is a
1664
+ * shape GUARD on unparsed data, not a discriminator between two meanings.
1665
+ */
1666
+ export function readOnSuccessNavigation(value) {
1667
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1668
+ return null;
1669
+ const navigate = value.navigate;
1670
+ if (typeof navigate !== 'string' || navigate === '')
1671
+ return null;
1672
+ return value;
1673
+ }
1674
+ /**
1675
+ * The runner's ONE `${scope.path}` token grammar — the substitution behind
1676
+ * `interpolateTarget` (url / api targets and the `onSuccess.navigate` hop) and
1677
+ * behind the success toast's `${result.*}` copy (`composeSuccessMessage`).
1678
+ *
1679
+ * The scope MAP defines the vocabulary: the pattern is built from its keys, so
1680
+ * a token naming a scope the caller did not pass is left in the text exactly as
1681
+ * written. A path that resolves to nothing becomes `''`. `encode` is the only
1682
+ * thing a caller varies, and it belongs to the SINK, not to the dialect: a URL
1683
+ * position percent-encodes each value, a toast inserts it as text.
1684
+ */
1685
+ function substituteScopeTokens(template, scopes, encode) {
1686
+ if (typeof template !== 'string' || template.indexOf('${') === -1)
1687
+ return template;
1688
+ const pattern = new RegExp(`\\$\\{(${Object.keys(scopes).join('|')})\\.([\\w.]+)\\}`, 'g');
1689
+ return template.replace(pattern, (_match, scope, path) => {
1690
+ const value = readPath(scopes[scope], path);
1691
+ if (value == null)
1692
+ return '';
1693
+ return encode(String(value));
1694
+ });
1695
+ }
1210
1696
  /**
1211
1697
  * Dot-path read used by target interpolation. Plain reduce — kept inline
1212
1698
  * to avoid pulling lodash for a 3-line helper.