Repository navigation
Expand file tree
/
Copy pathaction.zod.ts
More file actions
2401 lines (2331 loc) · 140 KB
/
Copy pathaction.zod.ts
File metadata and controls
2401 lines (2331 loc) · 140 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
import { retiredKey } from '../shared/retired-key';
import { FieldType } from '../data/field.zod';
// #6970 — the authoring gate on `defaultValue` runs the SAME value contract the
// dispatcher runs at submit, through the shared `defaultValue` discriminator
// module (#7127 — one module, two consumers; the literal-check core lives
// there). Imported file-directly (never via a barrel): `default-value-shape`
// and `field-value.zod` reach only `shared/` + `data/` + `system/`, and
// `action-params.zod` only `data/` + `api/` + `shared/`, so none can close a
// cycle back to `ui/`.
import { MULTI_CAPABLE_TYPES, isMultiValueField } from '../data/field-value.zod';
import { checkLiteralDefaultValue } from '../data/default-value-shape';
import { isActionParamValuePresent } from './action-params.zod';
// #17319 — the action's `execution` declaration is the bulk def's OWN enum,
// imported rather than re-declared: the maintainer ruling (decision batch #121
// item 3) admits no third spelling of the two dispatch contracts, and sharing
// the schema object is the only form of that which cannot drift. Imported
// file-directly for the reason the neighbours above are: `bulk-action.zod`
// reaches only `shared/` + `data/`, so it cannot close a cycle back to `ui/`.
import { BulkActionExecutionSchema } from './bulk-action.zod';
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
import { EvaluatedExpressionInputSchema } from '../shared/expression.zod';
import { evaluatedExpressionUnionRefusal } from '../shared/evaluated-slot-union';
import { I18nLabelSchema } from './i18n.zod';
import { HookBodySchema } from '../data/hook-body.zod';
// Imported file-directly (not via the kernel barrel): the module is
// deliberately import-free, so this cannot introduce a cycle.
import { PUBLIC_AUTH_FEATURE_NAMES, lowerRequiresFeature } from '../kernel/public-auth-features';
import { MEMBERSHIP_REACH_NAMES, lowerRequiresMembershipReach } from '../identity/membership-reach';
import { strictUnknownKeyError } from '../shared/suggestions.zod';
import { strictObject } from '../shared/strict-object';
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
import { lazySchema } from '../shared/lazy-schema';
import { aiJsonSchemaSlot } from '../shared/ai-json-schema-slot';
import { ACTION_TARGET_ALIASES } from './action-target-aliases';
/**
* Semantic near-misses — a different **word** for the same intent, usually
* borrowed from a neighbouring schema where that word is correct. Edit distance
* cannot reach these (`visibleWhen` → `visible` is 4 apart), so they are named
* explicitly; plain case/underscore slips (`help_text` → `helpText`) are left to
* the factory's edit-distance fallback. Mirrors the `FIELD_TYPE_ALIASES`
* pattern in `shared/suggestions.zod.ts`.
*
* Keys are matched case-insensitively with separators removed (see
* {@link strictUnknownKeyError}).
*/
const ACTION_PARAM_KEY_ALIASES: Readonly<Record<string, string>> = {
// The objectql/runtime field shape spells a lookup target `reference_to`, and
// objectui's resolved param calls it `referenceTo`. Dropping either is the
// exact #3405 failure: a targetless picker degrades to a raw-UUID text box.
referenceto: 'reference',
referenceobject: 'reference',
referencedobject: 'reference',
targetobject: 'reference',
// ADR-0089 made `visibleWhen` the canonical predicate on view/page schemas.
// An author who learned it there would silently lose a param's capability
// gate here — the param would render unconditionally.
visiblewhen: 'visible',
visibleon: 'visible',
visibility: 'visible',
description: 'helpText',
help: 'helpText',
default: 'defaultValue',
// The words an author borrows from `FieldSchema` (`readonly`) or widget
// vocabulary (`disabled`) for "the user must not edit this". On a param the
// declared contract is `carryOver` (commit 0e4e51b0a): non-editable AND still
// submitted verbatim — which is the half `readonly`'s field semantics
// (write-path strip) would get exactly wrong here.
readonly: 'carryOver',
disabled: 'carryOver',
};
/**
* Custom zod `error` for the `.strict()` {@link ActionParamSchema} (#3405 part 3).
*
* Before this, the schema was zod-default `.strip`: a key it does not declare was
* **silently discarded**, and the param went on parsing. That is how a correctly
* intended `reference: 'sys_user'` became a text box asking a human to paste a
* UUID, with no error anywhere — the config was eaten and the UI lied about why
* (ADR-0078 no-silently-inert-metadata, ADR-0049 enforce-or-remove).
*
* Built by {@link strictUnknownKeyError} — the shared factory this schema's
* hand-rolled #3746 map was generalized into (#4001): it names the offending
* key(s) and, when one is a recognisable spelling of a declared key, points at
* the canonical one.
*/
/**
* Guidance for `color` — declared one layer down on `SelectOptionSchema`
* (`data/field.zod.ts`), and still not a key of THIS shape.
*
* `visibleWhen` shared this text until #5016. The two were separated on
* measurement rather than on symmetry: both are declared on a FIELD's option
* list, but only one of them has a consumer an ACTION PARAM's option list can
* reach.
*
* - `visibleWhen` is now declared below, because the reader is on this path.
* An inline param's `options` are lowered VERBATIM (objectui
* `resolveActionParam`'s inline branch → `paramToField` →
* `getLazyFieldWidget`), and every option widget narrows the offered set
* through `useCascadingOptions` → `resolveCascadingOptions`, which reads
* exactly this key (ADR-0058 / objectui#2284).
* - `color` has no reader here. It is consumed only where a STORED value is
* displayed — the grid cell / detail badge (`SelectCellRenderer`) and the
* state-machine viewer. An action param's option list never reaches those:
* the dialog builds an input from it, submits the picked value, and drops
* the list. The select / multiselect / radio / checkboxes INPUT widgets read
* `label`, `value` and `visibleWhen`, and nothing else.
*
* So `color` here is not "not yet, pending #5016" — #5016 measured it and the
* answer is no. Declaring it would add exactly the key that parses clean and
* changes nothing (ADR-0078), and would delete the only sentence telling an
* author where the vocabulary IS real.
*/
const actionParamOptionColorGuidance =
'`color` is a per-option key of a FIELD\'s option list (`SelectOptionSchema` in '
+ '`data/field.zod.ts`), read where a STORED value is displayed — the grid cell and the '
+ 'detail badge. An action param\'s options are never rendered that way: the dialog builds '
+ 'an INPUT from them, submits the picked value and discards the list, so no renderer would '
+ 'read `color` here even if this shape declared it (measured this). Drop the key — to '
+ 'colour the value once it is stored, declare the option list on the FIELD.';
/**
* Guidance for per-option keys that no spec shape declares at all.
*
* `icon` / `disabled` exist only in objectui's internal `SelectOptionMetadata`
* interface, which nothing populates from metadata and no widget reads — so
* unlike `color` / `visibleWhen` there is no "one layer down" to point at, and
* saying there was would be the false-prescription class this campaign has
* already shipped four times (ledger finding 18).
*
* #5016 re-measured both before deciding whether to converge the spec on
* objectui's interface (its option C) and found the same thing the batch-14
* pass did: `SelectOptionMetadata.icon` has no reader anywhere in objectui, and
* every `disabled` in the four option widgets is the FIELD-level `props.disabled`,
* never a per-option one. C was therefore not taken.
*/
const actionParamOptionUndeclaredAnywhere = (key: 'icon' | 'disabled'): string =>
`no option shape in the spec declares \`${key}\` — not this one, and not the field-level `
+ `\`SelectOptionSchema\`. It exists only inside objectui's own `
+ `\`SelectOptionMetadata\` type, which no metadata path populates and no widget reads. An `
+ `action param's options are \`{ label, value, visibleWhen }\`; drop the key.`;
/**
* Action Parameter Schema
*
* Defines inputs required before executing an action.
*
* Two declaration modes:
*
* 1. **Field-backed** (preferred) — reference an existing object field; the
* runtime resolves the field's label (i18n), type, validation rules,
* options, placeholder, help text, and widget mapping from object
* metadata. Cross-object references use `objectOverride`.
*
* ```ts
* params: [
* { field: 'email' }, // same object
* { field: 'role', objectOverride: 'sys_member' }, // different object
* ]
* ```
*
* 2. **Inline** (legacy / bespoke) — declare `name`, `label`, `type` etc.
* inline when no matching object field exists. Inline values may also be
* used alongside `field` to override individual properties. A `lookup` /
* `master_detail` param declared this way MUST name its target object via
* `reference` — there is no field to inherit it from:
*
* ```ts
* params: [
* { name: 'inspector', label: 'Inspector', type: 'lookup', reference: 'sys_user' },
* ]
* ```
*
* `name` is required unless `field` is provided (in which case it defaults
* to the field name and is used as the request-body key).
*/
export const ActionParamSchema = lazySchema(() => strictObject(
{
surface: 'this action param',
aliases: ACTION_PARAM_KEY_ALIASES,
history:
'Until this shape was closed, these were dropped silently — the param still parsed, so a mis-spelled ' +
'config shipped as a control that quietly ignored it.',
},
{
/** Request-body key. Defaults to `field` when `field` is set. */
name: z.string().optional().meta({ title: 'Name' }),
/** Reference an existing object field for label/type/validation/options. */
field: SnakeCaseIdentifierSchema.optional().meta({ title: 'Field' }),
/** Object that owns the referenced field (defaults to the action's parent object). */
objectOverride: SnakeCaseIdentifierSchema.optional().meta({ title: 'Object Override' }),
/** Overrides the resolved field label (or sets it for inline params). */
label: I18nLabelSchema.optional().meta({ title: 'Label' }),
/** Overrides the resolved field type (or sets it for inline params). */
type: FieldType.optional().meta({ title: 'Type' }),
/**
* Required override; when omitted defaults to `false`. Consumers that wish
* to inherit the underlying field's `required` flag should leave this
* undefined in the source schema and resolve at runtime (the dialog
* renderers check truthiness, so `false === undefined` for UI purposes).
*/
required: z.boolean().optional().default(false).meta({ title: 'Required' }),
/**
* Select/picklist options override.
*
* #4001 批 14 closed the OPTION ENTRY. `ActionParamSchema` has been strict
* since #3405/#3746 — the file's template — but **strictness does not
* recurse**, so the entries inside `options` were still zod-default strip:
* the param was validated, its option list was not, and the shell reported
* success either way.
*
* **`strictObject`, not `.passthrough()` — measured, not inherited from the
* sibling.** `bulk-action.zod.ts`'s option entry went `.passthrough()`
* (#4909) and the reasoning there was specific: an authored bulk-action def
* is "left as-authored", reaches the grid VERBATIM, and objectui's
* `BulkActionParam` declares an explicit `[key: string]: unknown` catch-all,
* so `bulkParamToField`'s spread carries extras into a genuinely open widget
* vocabulary. Neither half of that holds here, and both were re-measured on
* 2026-08-03 rather than assumed:
*
* 1. **This surface has a parsing door, and the door already strips.** An
* action is a registered metadata type, so an authored param reaches
* objectui through `getMetadataTypeSchema('action')`
* (`MetadataManager.validate` / `GET /api/v1/meta` / the Studio form).
* Parsing a real action whose option carried
* `color` / `icon` / `disabled` / `visibleWhen` returned
* `{"label":"Overload","value":"overload"}` — every extra already gone,
* silently, before any renderer sees it. `.passthrough()` would therefore
* not be preserving a live flow; it would be *opening* one.
* 2. **The consumer type here is CLOSED, not a catch-all.** The dialog lowers
* a param through `paramToField` into field metadata, where the option
* vocabulary is objectui's `SelectOptionMetadata` — an enumerable
* interface (`label` / `value` / `color` / `icon` / `disabled` /
* `visibleWhen`), not an index signature. A closed target vocabulary is
* exactly the case where declaring beats tolerating.
*
* So the answer legitimately differs from the sibling's. What that left was a
* real, separable question — *should* an action param's option list speak the
* per-option vocabulary that a FIELD's `options` (`SelectOptionSchema`,
* `data/field.zod.ts`) already declares? That was filed as #5016 rather than
* guessed at here, and #5016 answered it **per key, on measurement**:
*
* - **`visibleWhen` — opened.** The reader is on this exact path and it works
* today. An inline param's `options` are lowered VERBATIM (objectui
* `resolveActionParam`'s inline branch does `options: param.options`;
* `ActionParamDialog` re-spreads each entry to localise `label`;
* `paramToField` passes the array straight into the widget's field
* metadata), and `SelectField` / `MultiSelectField` / `RadioField` /
* `CheckboxesField` all narrow the offered set through
* `useCascadingOptions` → `resolveCascadingOptions`, which reads this key
* and accepts the `{ dialect, source }` envelope
* `EvaluatedExpressionInputSchema` emits. The spec door was the ONLY thing between an author and a working
* per-option gate.
* - **`color` / `default` — not opened**, and `icon` / `disabled` not added to
* `SelectOptionSchema` either (#5016's option C). None has a reader an
* action param's option list can reach; each keeps a `guidance` entry
* saying where the vocabulary IS real. Declaring them would be the
* parses-clean-changes-nothing key ADR-0078 exists to keep out — and for
* `default` it would actively mislead, since a dialog param defaults
* through `defaultValue` one level up.
*
* **What this does NOT fix**, deliberately, because it is objectui's and not
* the spec's: a FIELD-BACKED param that inherits its list instead of declaring
* one still loses the key. `resolveActionParam` reaches
* `param.options ?? normaliseOptions(field.options, …)`, and `normaliseOptions`
* rebuilds every inherited entry as `{ label, value }`. That drop predates
* this change, is invisible to it (an authored `options` array wins over the
* inherited one), and is tracked in objectui — so the guidance below still
* refuses to prescribe "make it field-backed and inherit" (ledger finding 18:
* a confidently wrong prescription is worse than none).
*
* The aliases are anchored on `SelectOptionSchema`'s own curated table (the
* same idea, one layer down) rather than on edit distance, and deliberately
* carry across ONLY the entries whose target this shape actually declares —
* `never suggest a key the schema cannot accept` (ledger finding 12).
*/
options: z.array(strictObject({
surface: 'this action param option',
history:
'Until this shape was closed these were dropped silently — the param still '
+ 'rendered its picker, minus whatever the key was meant to colour, gate or disable.',
aliases: {
// Carried over from `SelectOptionSchema`'s table — same idea, and these
// five point at keys THIS shape declares.
text: 'label',
name: 'label',
title: 'label',
key: 'value',
id: 'value',
// objectql/import-export spell the stored side this way.
optionValue: 'value',
optionLabel: 'label',
displayName: 'label',
// #5016 declared `visibleWhen` here, so `SelectOptionSchema`'s two
// spellings for it now point at a key this shape accepts and can carry
// across under the same finding-12 rule as the five above.
visible: 'visibleWhen',
showWhen: 'visibleWhen',
},
guidance: {
// The per-option keys that are real one layer down but have no reader on
// THIS path, plus the two that no spec shape declares at all. Each says
// where the vocabulary lives and — critically — does NOT promise that a
// field-backed param inherits it: `resolveActionParams`' `normaliseOptions`
// rebuilds each inherited entry as `{ label, value }`, so that promise
// would be false in exactly the way ledger finding 18 warns about.
//
// `visibleWhen` is deliberately absent: it is a declared key now, and
// `guidance` is consulted only from the `unrecognized_keys` path, so an
// entry for it would be dead prose (`shared/alias-integrity.test.ts`).
color: actionParamOptionColorGuidance,
icon: actionParamOptionUndeclaredAnywhere('icon'),
disabled: actionParamOptionUndeclaredAnywhere('disabled'),
default: '`default` on an OPTION is the field-level picklist default (`SelectOptionSchema.default`). A dialog param defaults through `defaultValue` on the PARAM itself, one level up — write `defaultValue: \'<value>\'` there.',
},
}, {
label: I18nLabelSchema,
value: z.string(),
/**
* Per-option visibility predicate (CEL) — the option is offered only when
* this evaluates TRUE. Omit = always available (#5016).
*
* Same key, same engine and same binding environment as
* `SelectOptionSchema.visibleWhen` one layer down, so one vocabulary covers
* both surfaces: it expresses dependent options (`record.country == 'cn'`)
* AND role/context gating (`'admin' in current_user.positions`). In a
* dialog `record` is the live param bag overlaid on the row, so a param can
* gate its options on a SIBLING param the user has already filled.
*
* ⚠️ Client-side hiding is UX, not authorization. `enforceActionParams`
* validates the submitted value against this param's option VALUES
* (ADR-0104 D2) — it does not evaluate per-option `visibleWhen` — so an
* option gated for access-control reasons must also be refused by the
* action's own body or a permission check. Hiding it in the dropdown is
* bypassable.
*/
visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Same env as the field-level per-option visibleWhen (record + current_user). e.g. P`record.tier == 'gold'`"),
})).optional().meta({ title: 'Options' }),
/** Placeholder override. */
placeholder: z.string().optional().meta({ title: 'Placeholder' }),
/** Help/description override. */
helpText: z.string().optional().meta({ title: 'Help Text' }),
/**
* Default value for the dialog input — prefilled into the control when the
* dialog opens, and SUBMITTED VERBATIM if the user does not touch the field
* (objectui `ActionParamDialog` seeds its state with `p.defaultValue` and
* resolves no tokens against it).
*
* Because it is submitted verbatim it must satisfy this param's own value
* contract — the shape checked below through the SAME `valueSchemaFor` the
* dispatcher runs at submit. This is a LITERAL, not an expression surface:
* unlike a FIELD's `defaultValue`, which the ObjectQL engine resolves for
* runtime tokens (`current_user`, CEL `today()`; ROADMAP §M9.9b), nothing on
* the action-param path interprets this value.
*/
defaultValue: z.unknown().optional().meta({ title: 'Default Value' }),
/**
* Widget config for inline params (field-backed params inherit these from
* the referenced field at runtime; inline values override). The param
* dialog renders every param through the same field-widget renderer the
* object form uses (objectui ADR-0059), so these mirror the corresponding
* `FieldSchema` knobs.
*/
/** Allow multiple values (file/image/lookup/user params → array value). */
multiple: z.boolean().optional().describe('Allow multiple values (array value shape); mirrors FieldSchema.multiple.').meta({ title: 'Multiple' }),
/** Accepted upload types (MIME types / extensions) for `file`/`image` params. */
accept: z.array(z.string()).optional().describe('Accepted upload types (MIME types / extensions) for file/image params.').meta({ title: 'Accepted Types' }),
/** Max upload size in bytes for `file`/`image` params. */
maxSize: z.number().int().positive().optional().describe('Max upload size in bytes for file/image params.').meta({ title: 'Max Size (bytes)' }),
/**
* Reference target for an inline `lookup` / `master_detail` param — the
* object whose records the picker searches. Field-backed params inherit it
* from the referenced field, so it is only needed inline.
*
* Without it the dialog cannot query anything and degrades to a plain text
* input asking for a raw record id, which is unusable for a human — hence
* the `.refine()` below rejects a targetless lookup param at parse time.
*
* Key name deliberately mirrors `FieldSchema.reference` so the same spelling
* works in both places.
*/
reference: SnakeCaseIdentifierSchema.optional().describe('Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference.').meta({ title: 'Reference Object' }),
/**
* When true, the param's default value is pulled from the current row record
* (key = the resolved field name) when the action runs from a list_item
* context. Useful for edit dialogs that pre-fill from the selected row.
*/
defaultFromRow: z.boolean().optional().meta({ title: 'Default From Row' }),
/**
* Carry-over declaration (maintainer ruling 2026-08-25, commit 0e4e51b0a): the param's value is
* carried through the dialog rather than collected from the user — seeded
* from the current row (`defaultFromRow` is required alongside), rendered as
* a NON-EDITABLE summary, and submitted VERBATIM in the request body.
*
* The knob exists because neither neighbour expresses this contract:
*
* - `visible: false` omits the param from the dialog AND from the submission
* — the measurement commit 0e4e51b0a records; a clone action that hid its facet params this way
* would silently stop copying them, which is exactly the defect shape
* commit 5cb62d88b fixed in `clone_permission_set`.
* - Leaving the param editable invites the failure the ruling names: the
* clone dialog offered `member_default`'s `row_level_security` — a JSON
* array of 17+ policy objects — as a prefilled textarea on the platform's
* SANCTIONED clone path, where a hand-mangled-but-valid-JSON edit produces
* a clone granting MORE than its base, accepted without a word
* (`PermissionSetSchema` validates shape, not intent).
*
* "Not editable" is expressed by contract and enforced by the renderer
* (maintainer ruling 2026-08-25, recommendation A, commit 0e4e51b0a): objectui's
* `ActionParamDialog` renders a declared carry-over as a read-only summary
* while keeping the seeded value in its submit state, so what is declared is
* what is sent. Requiring `defaultFromRow: true` is the declared = enforced
* half at authoring time — a carry-over with no row seed would render an
* empty locked control and submit nothing, which is an authoring error, not
* a rendering decision (ADR-0078).
*/
carryOver: z.boolean().optional().describe(
'Carry-over param: seed the value from the current row (requires defaultFromRow: true), '
+ 'render it as a non-editable summary in the dialog, and submit it verbatim in the request '
+ 'body. Unlike `visible: false` (which omits the param from the submission entirely), a '
+ 'carry-over param is always sent.',
).meta({ title: 'Carry Over' }),
/**
* Visibility predicate (CEL) — same scope as the action-level `visible`
* (`current_user` / `data` / `features`). When it evaluates false the
* dialog omits this param entirely. Use it to hide a param that the backend
* only accepts under an opt-in capability, e.g. the create-user `phoneNumber`
* param gated on `features.phoneNumber` so the form never offers a field the
* default backend rejects. Absent = always visible.
*/
visible: EvaluatedExpressionInputSchema.optional().describe('Param visibility predicate (CEL); omits the param when false.').meta({ title: 'Visible When' }),
/**
* Declarative capability gate (#2874): name a public auth feature flag
* (see `PUBLIC_AUTH_FEATURES` in `@objectstack/spec/kernel`) and the schema
* lowers it at parse time into the canonical `visible` predicate —
* `features.X == true` (opt-in flag) or `features.X != false` (default-on),
* AND-composed with any explicit `visible`. The sugar key is stripped from
* the parsed output, so renderers/lint only ever see `visible`. Prefer this
* over a hand-written `features.*` predicate: the flag name is
* enum-checked and the gate/registry stay in lockstep.
*/
requiresFeature: z.enum(PUBLIC_AUTH_FEATURE_NAMES).optional().describe('Public auth feature flag gating this param; lowered into `visible` at parse time.').meta({ title: 'Requires Feature' }),
}).refine(
(p) => Boolean(p.name) || Boolean(p.field),
{ message: 'ActionParam requires either "name" or "field"' },
).refine(
// An INLINE record-picker param must name its target object. Only inline
// params are checked: a field-backed one inherits the target from the
// referenced field's metadata, which is not visible at parse time.
(p) => !(!p.field && (p.type === 'lookup' || p.type === 'master_detail') && !p.reference),
{
path: ['reference'],
message:
'ActionParam with type "lookup"/"master_detail" requires "reference" (the target object) when declared inline — without it the param dialog degrades to a raw record-id text input. Set `reference: \'<object>\'`, or use a field-backed param (`{ field: \'<lookup_field>\' }`) to inherit it.',
},
).refine(
// A carry-over param must have its row seed declared. The pair is checked at
// parse time because the failure it prevents is silent at runtime: a
// `carryOver: true` param with no `defaultFromRow` would render an empty
// read-only control and submit `undefined` — the silent-drop shape commit 5cb62d88b fixed,
// reintroduced through the very key added to close it.
(p) => !p.carryOver || p.defaultFromRow === true,
{
path: ['carryOver'],
message:
'ActionParam with "carryOver" requires "defaultFromRow: true" — a carry-over param is '
+ 'seeded from the current row, rendered read-only and submitted verbatim; without the row '
+ 'seed it would render an empty locked control and submit nothing. Declare '
+ '`defaultFromRow: true`, or (for a fixed value the user should not see) use the action\'s '
+ '`bodyExtra` instead.',
},
).superRefine((p, ctx) => {
// #6970 — an authored `defaultValue` is checked against the param's OWN
// declared value contract, through the SAME `valueSchemaFor` the dispatcher
// runs at submit (ADR-0104 D2, `validateActionParams`). One rule set, two
// moments: whatever the dispatcher would refuse from a user is refused from
// an AUTHOR, at the moment it is written.
//
// The gap this closes: `defaultValue` was `z.unknown()`, so a default that
// can never satisfy its own param parsed clean, prefilled the control, and
// 400'd at submit on a field the user never touched — with a message naming
// the param but not the author's default as the cause. `datetime` is the
// loudest instance (a human-readable wall clock, `2026-08-10T15:00`, which
// `datetime-local` happily displays and `InstantValueSchema` refuses) but the
// hole was every type: `number` + `'abc'`, `select` + a non-member, a
// `multiple` param + a scalar. That is the "AI writes it wrong in bulk and
// nothing says so" shape ADR-0078 / ADR-0049 exist to prevent.
//
// Checked ONLY where the declaration can answer the question — see the two
// skips below. An authoring gate that guessed at what a field-backed param
// inherits would reject valid metadata, which is worse than the silence it
// replaces.
if (!isActionParamValuePresent(p.defaultValue)) return;
// `type` is the param's own override; absent it is inherited from the
// referenced field at runtime and is not visible here (the same "leaves the
// value shape open" default `validateActionParams` applies to an
// unresolvable type).
if (!p.type) return;
// The whole present value goes down the LITERAL branch — deliberately no
// `discriminateDefaultValueShape` here. An action param's default is a pure
// literal (the dialog seeds it verbatim; `serializeParamValues` resolves
// nothing), so a runtime-token or envelope SPELLING is judged as the literal
// it would be at submit — the parity pin at the bottom of
// `action-param-default-value.test.ts` is the contract. The stance is
// recorded in `default-value-shape.ts`'s module note (#7127).
const def = { type: p.type, multiple: p.multiple, options: p.options };
const verdict = checkLiteralDefaultValue(def, p.defaultValue);
if (verdict.ok) return;
// ARITY is knowable only when the param states it. A field-backed param
// inherits `multiple` from its field, so `{ field: 'owners', type: 'user',
// defaultValue: ['a','b'] }` is a legal declaration whose array default this
// gate must not call wrong. When the param is field-backed AND silent on
// `multiple` AND the type is one whose arity `multiple` decides, accept
// either arity and check only the ELEMENT shape.
if (p.field && p.multiple === undefined && MULTI_CAPABLE_TYPES.has(p.type)) {
if (checkLiteralDefaultValue({ ...def, multiple: !isMultiValueField(def) }, p.defaultValue).ok) return;
}
const detail = verdict.detail ?? 'invalid value';
const key = p.name ?? p.field ?? '<unnamed>';
ctx.addIssue({
code: 'custom',
path: ['defaultValue'],
message:
`Action param "${key}" (${p.type}): the default ${JSON.stringify(p.defaultValue)} cannot `
+ `satisfy this param's own value contract — ${detail}. The dialog would PREFILL this value `
+ 'and the submit would then be refused with that same message (ADR-0104 D2), for a field the '
+ 'user never touched — so the 400 names the param but not this default, which is the real '
+ "cause. Write the default in the param's declared value shape, or drop `defaultValue`.",
});
}).transform((p, ctx) => lowerRequiresFeature(p, ctx)));
/**
* Action type enum values — the DISPATCH ROUTE of an action.
*
* The declarative single-record field write (#14092, maintainer ruling
* 2026-09-01) is deliberately NOT a member. It is spelled as a parallel key —
* `operation: 'update'` + `patch` — mirroring the list view's `bulkActionDefs`
* vocabulary word for word, and it rides the `script` route: the platform
* action route (`POST /api/v1/actions/<object>/<action>`) is where the write
* is performed, and `type: 'script'` is that route's name on an action. So
* `type` answers WHERE the action dispatches and `operation` answers WHAT the
* platform does there; an `operation: 'update'` action keeps `type` at its
* default and every other `type` beside it is refused (see
* {@link refuseDeclarativeUpdateContradictions}).
*
* Why not a member: objectui's `ActionRunner` types its dispatch table
* `Record<RunnableActionType, …>` on purpose, so a member added here stops the
* console compiling until an executor exists — a coupling the ruling's
* contract-first split (spec now, executor halves downstream) must not carry —
* and a member would be a second spelling of the bulk def's `operation`.
*/
export const ActionType = z.enum(['script', 'url', 'modal', 'flow', 'api', 'form']);
export type ActionType = z.input<typeof ActionType>;
/**
* Action types that require a `target` field.
* Derived from ActionType, excluding 'script' which allows inline handlers.
* These types reference an external resource (URL, flow, modal, or API endpoint)
* and cannot function without a target binding.
*/
const TARGET_REQUIRED_TYPES: ReadonlySet<string> = new Set(
ActionType.options.filter((t) => t !== 'script'),
);
/**
* Action Schema
*
* **NAMING CONVENTION:**
* Action names are machine identifiers used in code and must be lowercase snake_case.
*
* **TARGET BINDING:**
* The `target` field is the canonical way to bind an action to its handler.
* - `type: 'script'` — `target` is recommended (references a script/function name).
* - `type: 'url'` — `target` is **required** (the URL to navigate to).
* - `type: 'flow'` — `target` is **required** (the flow name to invoke).
* - `type: 'modal'` — `target` is **required** (the modal/page name to open).
* - `type: 'api'` — `target` is **required** (the API endpoint to call).
* - `type: 'form'` — `target` is **required** (the FormView name to open, routed to `/_console/forms/:name`).
* - `operation: 'update'` (on the default `script` route) — `target` is **refused**: the platform writes
* `patch` to the current record; there is nothing for a target to name (#14092).
*
* The `execute` alias was **removed in protocol 17** (#3855). `target` is the
* only handler slot, so no consumer has a second slot to disagree about. An
* authored `execute` is rejected with the rename prescription rather than
* silently stripped; `os migrate meta --from 16` lists the edit for you to apply.
*
* @example Good action names
* - 'on_close_deal'
* - 'send_welcome_email'
* - 'approve_contract'
* - 'export_report'
*
* @example Bad action names (will be rejected)
* - 'OnCloseDeal' (PascalCase)
* - 'sendEmail' (camelCase)
* - 'Send Email' (spaces)
*
* Note: The action name is the configuration ID. JavaScript function names can use camelCase,
* but the metadata ID must be lowercase snake_case.
*/
// Retired-VALUE prescription. Declared with `//` (never `/** */`) and ABOVE the
// enum's JSDoc deliberately: `build-docs.ts` takes a doc comment adjacent to the
// declaration as the reference entry's blurb, so a `/** */` here would displace
// the location table below. (House style set by the `crypto.hash` /
// `HookBodyCapability` and `array_agg` / `AggregationFunction` enum-value
// retirements — `data/hook-body.zod.ts`, `data/query.zod.ts`.)
const GLOBAL_NAV_RETIRED =
'`global_nav` was removed from `ACTION_LOCATIONS` in @objectstack/spec 17 (ADR-0049 '
+ 'enforce-or-remove) — no running-app surface ever rendered it. The console command palette '
+ '(`⌘K`) builds its groups from nav items, objects, dashboards, pages, reports, recent items '
+ 'and record search; it reads no action metadata at all, so an action declaring this location '
+ 'never reached a user. The only thing that DID draw it was the Studio designer, which '
+ 'previewed a command-palette frame for a surface the product does not have — an authoring '
+ 'tool teaching authors to write dead metadata (ADR-0078). Place the action on a location a '
+ 'renderer serves (`list_toolbar`, `list_item`, `record_header`, `record_more`, '
+ '`record_related`, `record_section`), or — for an action that deliberately has no UI home, '
+ 'such as an object-less one invoked over REST/MCP/AI — declare it headless with '
+ '`locations: []`, which keeps its capability gate, param contract and audit trail. '
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.';
/**
* Action Location — where an action is allowed to surface in the UI.
*
* Canonical list (single source of truth for the whole platform). Renderers,
* the ActionEngine, the Studio designer dropdowns, and `objectui` consumers
* MUST import from this constant rather than re-declaring their own enum —
* adding a new location should require touching this one file only.
*
* Semantics:
* - `list_toolbar` — header/toolbar of a list view (bulk actions, "New", export).
* - `list_item` — per-row action on a list/grid row (Salesforce row-level menu).
* - `record_header` — primary actions in the record-detail title bar.
* - `record_more` — overflow menu under the "More" / ⋯ button on a record.
* - `record_related` — per-row action on each row of a related list shown inside
* a parent record, in that parent's context only. Unlike
* `list_item` (every row wherever the object is listed), it
* never surfaces on the object's own list views.
* - `record_section` — actions surfaced inside a body section/tab of a record
* (e.g. a Security tab grouping change-password, 2FA, etc.).
*
* `global_nav` was REMOVED in 17 (#6888, maintainer ruling 2026-08-09). It had
* been declared here since the vocabulary was written and no product surface
* ever served it: the console's ⌘K palette composes its groups from nav items,
* objects, dashboards, pages, reports, recent items and record search, and
* references no action metadata. Four of the five references to the value in
* the whole UI repo were the Studio designer — which drew the author a mock
* "⌘K · Command palette" frame, promising a rendering the product cannot do
* (the ADR-0078 declares-renders-does-nothing shape, arriving through the
* location vocabulary rather than through a missing key). Retired rather than
* implemented: no user has asked for command-palette actions and the only two
* declarers were our own showcase corpus, so wiring the palette would have been
* capability expansion with no pull. This is an enum VALUE, not a key, so there
* is no `retiredKey()` tombstone — the prescription lives on the enum's own
* error map above, keyed on `issue.input` so that only the spelling which used
* to be legal is told it "was removed". An object-less action's honest
* declaration is `locations: []` (headless), not a location nothing renders.
*/
export const ACTION_LOCATIONS = [
'list_toolbar',
'list_item',
'record_header',
'record_more',
'record_related',
'record_section',
] as const;
export const ActionLocationSchema = z.enum(ACTION_LOCATIONS, {
// Only the spelling that USED to be legal gets the retirement message.
// Telling the author of `globalnav` that their value "was removed" would
// misinform, so everything else keeps zod's own enum error, which already
// lists the legal locations. (`array_agg` / `crypto.hash` precedent.)
error: (issue) => (issue.input === 'global_nav' ? GLOBAL_NAV_RETIRED : undefined),
});
export type ActionLocation = z.input<typeof ActionLocationSchema>;
/**
* Tool category values for {@link ActionAiSchema.category}.
*
* **Canonical.** This was a hand-copy of `ToolCategorySchema` in
* `../ai/tool.zod`, kept inline rather than imported to avoid a `ui → ai`
* cycle, under a comment telling the next author to update both sides. #3896
* removed `ToolCategorySchema` along with the inert `tool.category` key it
* typed — which left that instruction pointing at a source that no longer
* exists, and a reader hunting for a second side there is none of. This enum
* is now the only declaration of the vocabulary: change it here, nowhere
* else. (#3786 — comments are not a mechanism, and they rot silently.)
*/
const ActionAiCategorySchema = z.enum([
'data',
'action',
'flow',
'integration',
'vector_search',
'analytics',
'utility',
]);
/**
* AI exposure block (ADR-0011 "Actions as AI Tools").
*
* **Opt-in, default off.** An action becomes an AI-callable tool only when
* `exposed: true`. This is a deliberate governance gate: in an AI-authoring
* world the platform's value is that a human can govern exactly which
* capabilities the agent fleet is allowed to invoke — a half-finished or
* unreviewed action must never be silently armed.
*
* When exposed, `description` is **required** — it is the LLM-facing contract
* (when/why to call), authored explicitly rather than derived from the
* UI `label`. The bridge in `@objectstack/service-ai` translates this block
* into an `AIToolDefinition`.
*/
/**
* Shared history for this file (#4001).
*
* `ActionParamSchema` has been strict since #3746 — the campaign's own template,
* where `visibleWhen` → `visible` proved that the most valuable alias entry is
* rarely a typo but a key that reads as a control and silently is not one. The
* action AROUND the param stayed open for three more releases.
*/
const ACTION_HISTORY =
'Until this shape was closed these were dropped silently — the action still registered '
+ 'and still ran, without whatever the key was meant to configure or gate.';
export const ActionAiSchema = strictObject({
surface: "this action's AI exposure block",
history: ACTION_HISTORY,
aliases: {
enabled: 'exposed', enable: 'exposed', aiEnabled: 'exposed', expose: 'exposed', visible: 'exposed',
prompt: 'description', toolDescription: 'description', summary: 'description',
type: 'category', kind: 'category', toolCategory: 'category',
hints: 'paramHints', parameterHints: 'paramHints', params: 'paramHints',
returns: 'outputSchema', responseSchema: 'outputSchema', output: 'outputSchema',
confirm: 'requiresConfirmation', requireConfirmation: 'requiresConfirmation', hitl: 'requiresConfirmation', humanInTheLoop: 'requiresConfirmation',
},
guidance: {
// This block IS the governance gate — the doc above says a half-finished or
// unreviewed action must never be silently armed. A near-miss here is
// therefore the worst kind on this surface: the author believes they set a
// gate, and the gate does not exist. Name the two people reach for.
permissions:
'AI invocation is not gated by a key here — an agent reaches this action only if a '
+ "surface-compatible SKILL declares it (ADR-0064), and who may talk to that agent is "
+ "gated by the agent's `access` / `permissions`. "
+ 'For a human-approval step on the call itself, use `requiresConfirmation: true`.',
approval:
'there is no approval workflow key here — `requiresConfirmation: true` forces a '
+ 'human-in-the-loop gate on the AI call. A multi-step business approval is an `approval` '
+ 'metadata item, not an action field.',
},
}, {
/**
* Expose this action to AI agents as a callable tool. Default `false`.
* Setting `true` REQUIRES `description`.
*/
exposed: z.boolean().default(false).describe('Expose this action to AI agents. Requires `description` when true.'),
/**
* LLM-facing description: tells the model when and why to call this action.
* Distinct from the UI `label`. Plain English, ≥ 40 chars for useful tool
* selection. Required whenever `exposed` is true.
*/
description: z.string().min(40).optional().describe('LLM-facing description (≥40 chars). Required when exposed.'),
/**
* Override the derived tool category. Defaults to `action` (side-effect).
* Use `data` for read-only actions, `analytics` for aggregations, etc.
*/
category: ActionAiCategorySchema.optional().describe('Tool category override (defaults to "action").'),
/**
* Per-parameter AI hints, keyed by param name (or the injected `recordId`).
* Tightens the JSON Schema the LLM sees (e.g. add `enum`, override
* `description`, supply `examples`) WITHOUT changing the UI-facing field
* metadata. Keys must match a declared `params[].name` (or `recordId`).
*/
paramHints: z.record(z.string(), strictObject({
surface: 'this AI parameter hint',
history: ACTION_HISTORY,
aliases: { desc: 'description', hint: 'description', values: 'enum', options: 'enum', choices: 'enum', allowed: 'enum', example: 'examples', sample: 'examples' },
}, {
description: z.string().optional(),
enum: z.array(z.union([z.string(), z.number()])).optional(),
examples: z.array(z.unknown()).optional(),
})).optional().describe('Per-parameter AI hints keyed by param name.'),
/**
* Output JSON Schema for the action's return value. Enables structured
* downstream tool chaining (one action's output feeds another's input) and
* is summarised into the tool description so the model knows what it gets
* back. Optional — when omitted the return value is treated as freeform.
*
* The cloud AI runtime compiles this schema before the action runs, and its
* schema reader refuses an untyped subschema that carries a type-scoped
* keyword (`properties`, `items`, `pattern`, `minimum`, …). The slot refuses
* the same schemas here, at the subschema's path, through the one factory
* `agent.structuredOutput.schema` shares (`shared/ai-json-schema-slot.ts`).
*/
outputSchema: aiJsonSchemaSlot('ai.outputSchema').optional().describe(
'JSON Schema for the action return value. An untyped subschema that carries a type-scoped '
+ 'keyword (properties, items, pattern, minimum, …) is refused at its path, because the AI '
+ 'runtime\'s schema reader does not check it; declare its "type".',
),
/**
* Override confirmation for AI calls. When unset, the bridge defaults to
* `true` for actions that look destructive: `mode:'delete'` or
* `variant:'danger'` (#7828 Option A). `confirmText` is dialog copy, not a
* destructive signal. Set explicitly to `false` to assert such an action is
* safe without human approval, or `true` to gate an otherwise-safe one.
*/
requiresConfirmation: z.boolean().optional().describe('Override HITL confirmation for AI invocations.'),
});
export type ActionAi = z.input<typeof ActionAiSchema>;
/** Post-parse shape of {@link ActionAi} — defaults applied, transforms run (ADR-0122). */
export type ActionAiParsed = z.infer<typeof ActionAiSchema>;
/**
* The shape both action-level condition keys speak — `visible` and `disabled`.
*
* Three arms for one meaning, cheapest first:
*
* | arm | example | meaning |
* |:---|:---|:---|
* | `boolean` | `visible: false` | the degenerate literal — a condition that is settled at authoring time |
* | `string` | `disabled: "record.status == 'closed'"` | CEL shorthand, normalized to the envelope at parse time |
* | `{ dialect, source }` | `{ dialect: 'cel', source: '…', meta: { rationale } }` | the full envelope, for authorship metadata or a non-default dialect |
*
* The two keys were asymmetric until commit 97e7e3caa — `visible` had no `boolean` arm, so
* the very common `visible: true` was a parse error on the spec side while
* objectui's `ActionDef` accepted it and stored metadata was already written
* that way. An asymmetry between two keys that mean the same *kind* of thing is
* a dialect nursery: it teaches each consumer to keep its own widening (the
* `(action as any).disabled` cast in console's `DeclaredActionsBar` was exactly
* that), and every one of those is a second de-facto contract (Prime Directive
* #12). Unifying here is what lets #4075 step 3 derive `ActionDef` from this
* schema and delete the casts.
*
* The boolean arm is deliberately NOT normalized into `{dialect:'cel',
* source:'true'}`: a literal survives as a literal, so a renderer can branch on
* it without standing up an evaluator, and `false` stays statically greppable.
*/
const ActionConditionInputSchema = z.union([z.boolean(), EvaluatedExpressionInputSchema], {
error: (issue) => evaluatedExpressionUnionRefusal(issue.input),
});
/**
* The object half of {@link ActionSchema}, before its refinements.
*
* A factory rather than a schema so `lazySchema`'s deferral still holds — the
* fields are built on first use of whichever schema derives from them, not at
* module load.
*
* It exists because `.pick()` is a `ZodObject` method and `ActionSchema` is
* `z.object(…).refine(…).refine(…)`, so nothing can derive a subset from the
* exported schema. {@link InlineActionSchema} derives from this instead of
* restating a dozen field definitions and their `describe()` text, which is how
* a second action vocabulary would start.
*/
const actionObject = () => strictObject({
surface: 'this action',
history: ACTION_HISTORY,
aliases: {
title: 'label', displayName: 'label', text: 'label',
object: 'objectName', entity: 'objectName',
actionType: 'type',
// The executor-target family lives in ONE table the `action:button` /
// `action:icon` rows read too (`action-target-aliases.ts`), so the action
// and the blocks that run it print the same rename (#21005).
...ACTION_TARGET_ALIASES,
parameters: 'params', args: 'params', inputs: 'params', fields: 'params',
confirm: 'confirmText', confirmation: 'confirmText', confirmMessage: 'confirmText',
success: 'successMessage', successText: 'successMessage', toast: 'successMessage',
visibleWhen: 'visible', showWhen: 'visible',
disabledWhen: 'disabled',
style: 'variant', color: 'variant', appearance: 'variant',
placement: 'locations', location: 'locations', position: 'locations',
verb: 'method', httpMethod: 'method',
// #14092 — the bulk def's own shorthand for `operation`, and the words an
// author borrows for "the field values to write" (`values`, `set`, or the
// verb itself) all rename onto the two declarative-update keys.
op: 'operation', values: 'patch', set: 'patch', update: 'patch',
// #17319 — the words an author reaches for when declaring which bulk
// dispatch contract the body was written for. The card that filed the gap
// proposed `dispatch`, so that spelling is the one most likely to be
// typed; the canonical key is `execution`, the bulk def's own.
// ⛔ NOT `mode`: the def aliases `mode` onto `execution`, but on an ACTION
// `mode` is a DECLARED key (create/edit/delete/custom), so renaming it here
// would eat a real declaration.
dispatch: 'execution', dispatchContract: 'execution',
bulkExecution: 'execution', bulkDispatch: 'execution',
// #5013 — `body` is DECLARED on this schema (the `script` action's L1/L2
// hook body), so an alias filed under it could never run; `payload` is the
// live spelling that still needs pointing at `bodyExtra`.
payload: 'bodyExtra',
llm: 'ai', tool: 'ai',
dialog: 'resultDialog', result: 'resultDialog',
refresh: 'refreshAfter', reload: 'refreshAfter',
// The capability gate on an action IS a declared key — `requiredPermissions`
// (ADR-0066 D4), enforced with a 403 on the platform action route. So the
// near-misses of it must RENAME onto it, never be told the gate lives
// somewhere else.
permissions: 'requiredPermissions', capabilities: 'requiredPermissions',
requiresPermissions: 'requiredPermissions', requiredCapabilities: 'requiredPermissions',
acl: 'requiredPermissions',
},
guidance: {
// `visible` / `disabled` are the trap worth naming on this surface: they
// look like access control and are not. The real gate is
// `requiredPermissions`, which is why the near-misses above rename onto it
// rather than pointing anywhere else.
hidden:
'`hidden` is not an action key, and hiding is not gating — `visible` and `disabled` are UI '
+ 'predicates that hide or grey a button, they do not stop a request. To actually gate '
+ 'invocation use `requiredPermissions` (ADR-0066 D4, enforced with a 403 on the platform '
+ 'action route). To declare an action with no UI surface at all, set `locations: []`.',
// Two AI-block keys authors reach for at the top level. Silently stripping
// either meant an action was armed for agents, or left ungated, in silence.
exposed:
'AI exposure lives under `ai` — write `ai: { exposed: true, description: … }`. The '
+ 'description is the LLM-facing contract and is REQUIRED (≥40 chars) whenever exposed.',
requiresConfirmation:
'the AI human-in-the-loop override lives under `ai` — write '
+ '`ai: { requiresConfirmation: true }`. `confirmText` is the separate UI confirm prompt.',
// The three spellings #9474 measured authors probing for post-success
// navigation before `onSuccess` existed. Each is a top-level string where
// the declared shape is a nested object, so an alias rename would produce
// a second, worse error (`invalid_type` at `onSuccess`) — a guidance
// pointer carries the whole rewrite instead.
redirect:
"post-success navigation is declared under `onSuccess` — write `onSuccess: { navigate: "
+ "'<route/URL template>' }` (interpolates ${param.*}, ${ctx.*} and ${result.*}, the server "
+ "response). Read for `type: 'api'` and `type: 'script'` actions; `openIn: 'self' | 'newTab'` "
+ "picks the tab (default 'self').",
navigate:
'`navigate` is not a top-level key — it lives inside `onSuccess`: write '
+ "`onSuccess: { navigate: '<route/URL template>' }`. The template interpolates ${param.*}, "
+ '${ctx.*} and ${result.*} (the server response payload, e.g. ${result.id}).',
redirectUrl:
'`redirectUrl` is the HANDLER-RETURN convention (a server handler returns '
+ '`{ redirectUrl, openIn? }`), not an authorable action key. To declare the destination in '
+ "metadata, write `onSuccess: { navigate: '<route/URL template>' }` — ${result.*} "
+ 'interpolates the server response.',
},
}, {
/** Machine name of the action */
name: SnakeCaseIdentifierSchema.describe('Machine name (lowercase snake_case)'),
/** Display label */
label: I18nLabelSchema.describe('Display label'),
/**
* Explanatory line shown in the action's PARAM DIALOG, under the title.
*
* The renderer half already exists and predates this key: objectui's
* `ActionParamDialog` renders it as the dialog's `DialogDescription`
* (`objectui packages/app-shell/src/views/ActionParamDialog.tsx:215`, falling
* back to the generic `actionDialog.description` string), fed by
* `actionDescription(objectName, actionName, action.description)` from two
* independent handlers — `useConsoleActionRuntime.tsx:206` and
* `RecordDetailView.tsx:586`. The resolver
* (`objectui packages/i18n/src/useObjectLabel.ts:463`) reads
* `objects.{object}._actions.{action}.description`, falls back to
* `globalActions.{action}.description`, then to this literal. Until #7367 no
* producer could reach any of it: this shape refused the key.
*
* **Use it for the question the dialog is asking.** An action that collects
* params and ALSO sets `confirmText` shows the user two dialogs for one
* decision — the confirm, then the param prompt. The maintainer's 2026-08-10
* ruling on #7278 is to carry the confirm question here instead: one
* condition, one wording, one dialog, nothing sent until its own Confirm.
* `confirmText` stays correct for a param-LESS action, where the confirm IS
* the only dialog. The describe below is Studio form help and says only "one
* dialog, not two"; the ruling that decided it is recorded here (#22093).
*
* **Not `ai.description`.** That one is the LLM-facing tool contract
* (≥40 chars, required when `ai.exposed`); this one is human-facing dialog
* copy and is never sent to a model.
*/
description: I18nLabelSchema.optional().describe('Explanatory line shown under the title in the action\'s param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`.'),
/** Target object this action belongs to (optional, snake_case) */
objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional().describe('Target object this action belongs to. When set, the action is auto-merged into the object\'s actions array by defineStack().'),
/** Icon name (Lucide) */
icon: z.string().optional().describe('Icon name'),
/** Where does this action appear? */
locations: z.array(ActionLocationSchema).optional().describe('Locations where this action is visible'),
/**
* Visual Component Type
* Defaults to 'button' or 'menu_item' based on location,
* but can be overridden.