-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathfield.zod.ts
More file actions
2434 lines (2341 loc) · 152 KB
/
Copy pathfield.zod.ts
File metadata and controls
2434 lines (2341 loc) · 152 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 { strictObject } from '../shared/strict-object';
// Package-internal, like `strict-object` itself — the `shared/index.ts` barrel
// deliberately does not re-export it, so nothing about the public API surface
// moves. No cycle back into this file: that module's only runtime import is
// `shared/visibility.ts`, which imports nothing at runtime.
import { SELECT_OPTION_EDITABILITY_GUIDANCE } from '../shared/editability-boundary';
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
import { SystemIdentifierSchema } from '../shared/identifiers.zod';
import { ExpressionInputSchema } from '../shared/expression.zod';
import { FilterConditionSchema } from './filter.zod';
import { FIELD_KEY_GUIDANCE } from './authoring-key-lint';
import { DEFAULT_AUTONUMBER_FORMAT } from './autonumber-format';
// #7127 — the `defaultValue` authoring gate: shape discrimination (literal /
// runtime token / CEL envelope), the per-token × per-type table, and the
// shared literal-vs-stored-contract check. `default-value-shape` reaches
// `field-value.zod`, whose only import back into THIS file is the type-only
// `FieldType` (erased at runtime) — `AddressSchema` moved there (see its
// re-export below), so the edge is one-way and no runtime ESM cycle closes.
import {
checkLiteralDefaultValue,
defaultValueTokenIssue,
discriminateDefaultValueShape,
suggestDefaultValueToken,
} from './default-value-shape';
import { AddressSchema } from './field-value.zod';
// #7918 — the ISO 4217 / CLDR fraction-digit contradiction check (maintainer
// ruling 2026-08-12, Option A). One shared verdict for both anchors: the
// field-level `precision` key and `CurrencyConfigSchema.precision`.
import { currencyPrecisionContradiction } from './currency-fraction-digits';
import { ValueDomainSchema } from '../shared/value-domain.zod';
/**
* Field Type Enum
*/
import { lazySchema } from '../shared/lazy-schema';
export const FieldType = z.enum([
// Core Text. 'password' on a generic (non-better-auth) object is plaintext at
// rest but masked to SECRET_MASK on read — the auth subsystem's one-way
// hashing applies only to its own identity tables, never to an authored
// 'password' field. Prefer 'secret' for reversible machine credentials. See
// ADR-0100.
'text', 'textarea', 'email', 'url', 'phone', 'password',
// Secret — reversible, encrypted-at-rest value (DB password, API key, token).
// UNLIKE 'password' (masked-on-read but plaintext at rest, or one-way hashed
// inside the auth subsystem), a 'secret' is round-tripped: the engine encrypts
// it on write via the registered ICryptoProvider, stores the ciphertext handle
// in `sys_secret`, persists only an opaque ref on the row, and masks it on
// read. Fail-closed: no provider ⇒ writes throw rather than persist cleartext.
// See ADR-0100.
'secret',
// Rich Content
'markdown', 'html', 'richtext',
// Numbers
'number', 'currency', 'percent',
// Date & Time
'date', 'datetime', 'time',
// Logic
'boolean', 'toggle', // Toggle is a distinct UI from checkbox
// Selection
'select', // Single select dropdown
'multiselect', // Multi select (often tags)
'radio', // Radio group
'checkboxes', // Checkbox group
// Relational
'lookup', 'master_detail', // Dynamic reference
'tree', // Hierarchical reference
// User reference — a lookup specialized to the `sys_user` system object (person
// picker; single, or multiple for collaborators/watchers). Stored IDENTICALLY to
// 'lookup' (FK string column → sys_user.id; `multiple` ⇒ JSON) and resolved via the
// same $expand machinery. The distinct type exists for modelling discoverability
// (Studio/AI field palette), the user-search picker, and `current_user` defaults —
// NOT a separate storage primitive. Ownership stays the existing `owner_id`
// convention (plugin-security); a declarative `owner` is a possible future flag.
'user',
// Media
'image', 'file', 'avatar', 'video', 'audio',
// Calculated / System
'formula', 'summary', 'autonumber',
// Embedded structured values (stored as JSON on the parent row — no separate table / FK)
'composite', // Single embedded sub-object with declared sub-fields (≈ Strapi component / ACF group)
'repeater', // Repeating embedded sub-object array with declared sub-fields (≈ Strapi repeatable component / ACF repeater)
'record', // Name-keyed map of embedded sub-objects (Record<string, SubObject>). Insertion order = display order. Used for collections where each item has a stable machine name (e.g. object.fields). See ADR-0007.
// Enhanced Types
'location', // GPS coordinates
'address', // Structured address
'code', // Code editor (JSON/SQL/JS)
'json', // Structured JSON data (untyped escape hatch)
'color', // Color picker
'rating', // Star rating
'slider', // Numeric slider
'signature', // Digital signature
'qrcode', // QR code / Barcode
'progress', // Progress bar
'tags', // Simple tag list
// AI/ML Types
'vector', // Vector embeddings for AI/ML (semantic search, RAG)
]);
export type FieldType = z.input<typeof FieldType>;
/**
* Field types whose stored value is a BOUNDED STRING — the set on which a
* `maxLength` / `minLength` character bound describes something that is
* actually stored (#11566, maintainer ruling 2026-08-24).
*
* This is the write-time validator's own enforcement list (objectql
* `record-validator.ts`, the string-types branch) promoted to the protocol:
* three lists used to disagree (field.form showed the key for 3 types,
* object.form for 9, the validator enforced 10), and the validator's ten is
* the only one with a measured reader. `FieldSchema` refuses `maxLength`
* outside this set (see the superRefine below) — and, per the #11949 ruling
* (2026-08-25), `minLength` too: the twin defect pair converges on the same
* template — and the two authoring forms show both keys for exactly this
* set — declared converges to enforced (ADR-0078).
*
* `signature` / `qrcode` joined in #11875 (maintainer ruling 2026-08-25,
* option 1): their stored value IS the author's own string and routinely far
* past 255 characters (a data-URI PNG for `signature`), and the write-time
* validator now enforces their declared bound — declared = enforced holds in
* both directions, which is also what licenses their unbounded TEXT column in
* `driver-sql` (the #11794 invariant: TEXT is permitted exactly because the
* write seam enforces the declared `maxLength`).
*
* Deliberately NOT here: `secret` (stored ciphertext handle — the authored
* value's length is not what the column holds; ADR-0100 — explicitly outside
* the #11875 ruling), `color` (short by construction — same ruling),
* `select`/`multiselect` (bounded by their options, not by a character count),
* `json`/`code`-adjacent structured types other than `code` itself, and every
* non-string type.
*/
export const BOUNDED_STRING_FIELD_TYPES: ReadonlySet<string> = new Set([
'text', 'textarea', 'email', 'url', 'phone', 'password',
'markdown', 'html', 'richtext', 'code',
// #11875 — the write seam enforces these two's declared bound (see above).
'signature', 'qrcode',
] as const satisfies readonly FieldType[]);
/**
* Field types on which `valueDomain` is authorable — the set whose stored
* value is ONE plain string that names a member of a published standard
* (maintainer ruling 2026-09-02, option A on #14168: a field-level
* `valueDomain` drawn from the settings specifier's closed vocabulary; see
* `shared/value-domain.zod.ts`).
*
* Measured against BOUNDED_STRING_FIELD_TYPES (the `maxLength` / `minLength`
* family, twelve types) and deliberately NARROWER. A domain member is a short
* identifier (`UTC`, `CHF`, `CH`) and the whole stored value is that
* identifier, so only the type that stores a single plain string qualifies.
* The other eleven store something else: `textarea` / `markdown` / `html` /
* `richtext` / `code` store a multi-line body; `email` / `url` / `phone`
* already carry their own shape family and a currency code is never an
* email; `password` stores a masked credential (ADR-0100); `signature` /
* `qrcode` store a data URI. Declaring a domain on any of those would parse
* and describe nothing that is stored — the declared-but-inert shape
* ADR-0078 keeps out — so `FieldSchema` refuses it there (the superRefine
* below, the #11566 template).
*
* `select` is NOT here, on purpose: a select's membership boundary is its
* `options` table, exhaustive on the write path. The settings specifier lets
* a domain DEMOTE `options` to a suggestion list; importing that semantics
* onto fields is a second ruling, not a widening of this set.
*/
export const VALUE_DOMAIN_FIELD_TYPES: ReadonlySet<string> = new Set([
'text',
] as const satisfies readonly FieldType[]);
/**
* Field types whose value is edited in a MULTILINE text editor whose inline
* (non-fullscreen) surface is sized by the HTML `rows` attribute — the set on
* which an authored `rows` height hint reaches a real reader (objectui#6140,
* maintainer ruling 2026-08-25, Option A).
*
* Measured from the consuming widgets, the #11566 method (the list with a
* measured reader is the one promoted to the protocol): objectui's
* `TextAreaField` reads the key for `textarea` (`textareaField?.rows || 4`),
* and `RichTextField` — the one widget registered for the `markdown`, `html`
* and `richtext` keys (objectui#5498) — reads it for the other three
* (`richField?.rows || 8`, passed to the inline editor surface; the
* fullscreen/dialog surface sizes itself and ignores it). The RULED pair is
* `markdown`/`html` — the two objectui metadata types that lacked the
* declaration, while `TextareaFieldMetadata` already declared `rows` and is
* the precedent the ruling cites. `textarea`/`richtext` are members because
* the same measured read serves them and this schema refuses the key on
* EVERY type today — declaring the ruled pair while still refusing the
* precedent type's own declared key would manufacture a fresh
* declared-vs-enforced split on the type the ruling aligns to.
*
* Deliberately NOT here: `code` (its editor has no `rows` read — the only
* `rows` occurrences in objectui's field widgets are the two quoted above),
* and every single-line string type.
*/
const MULTILINE_EDITOR_FIELD_TYPES: ReadonlySet<string> = new Set([
'textarea', 'markdown', 'html', 'richtext',
] as const satisfies readonly FieldType[]);
/**
* Field types whose stored value the RUNTIME owns outright — issued by the
* engine (or the driver's persistent sequence), never supplied by a caller on
* either write path. Today exactly `autonumber` (#5503).
*
* This is the PROTOCOL's statement of that ownership, so the consumers that act
* on it read one vocabulary instead of each carrying its own literal: objectql's
* write-path strips (`isRuntimeOwnedField` / `stripRuntimeOwnedFields`, which
* treat these types as implicitly read-only), and the create-side static
* `readonly` strip, which EXCLUDES these types rather than pre-empting a
* whitelist it does not implement (`staticReadonlyInsertSubject`, #5628/#14147
* — the exclusion the deleted DataProtocol ingress copy used to carry).
*
* Keep the set to types whose value is (a) persisted, (b) issued by the runtime,
* and (c) never legitimately supplied by a caller. `formula` and `summary` are
* deliberately NOT here: they are derived-on-read/roll-up, not stored values a
* caller could forge into a sequence.
*
* A runtime-issued value is issued AS AN IDENTIFIER, and an identifier that
* may repeat is not one — so since #13894 (maintainer ruling 2026-08-31 on
* hotcrm#1301) a field of one of these types also defaults to
* `unique: 'organization'` when the author omits `unique` (materialized in
* `FieldSchema`'s `.overwrite()` tail, the `case_number` template); an
* authored `unique: false` opts out. This set is NOT what that default reads —
* it keys on `type === 'autonumber'` directly, the same test the platform's
* duplicate scan (`os migrate duplicates`) uses to call a field an identifier
* — but the two facts belong to the same ownership: the runtime mints it, the
* runtime keeps it unique.
*/
export const RUNTIME_OWNED_FIELD_TYPES: ReadonlySet<string> = new Set<string>(['autonumber']);
/**
* Select Option Schema
*
* Defines option values for select/picklist fields.
*
* **CRITICAL RULE**: The `value` field is a machine identifier that gets stored in the database.
* It MUST be lowercase to avoid case-sensitivity issues in queries and comparisons.
*
* @example Good
* { label: 'New', value: 'new' }
* { label: 'In Progress', value: 'in_progress' }
* { label: 'Closed Won', value: 'closed_won' }
*
* @example Bad (will be rejected)
* { label: 'New', value: 'New' } // uppercase
* { label: 'In Progress', value: 'In Progress' } // spaces and uppercase
* { label: 'Closed Won', value: 'Closed_Won' } // mixed case
*/
/**
* Shared history for the authorable shapes in this file (#4001).
*
* This object carries more silently-stripped keys than any other in the spec,
* and it documented the fact about itself for two releases: the notes below on
* `accept`/`maxSize` and on the five pruned governance keys both say a write
* "parsed clean and the key was silently stripped", and both call it the
* ADR-0104 failure class. `FieldSchema` was not `.strict()`, so the only
* available fix each time was a comment. This is the fix those comments wanted.
*/
const FIELD_HISTORY =
'Until this shape was closed these were dropped silently — the field was still created, '
+ 'minus whatever the key was meant to constrain, protect or compute.';
/**
* ## An option is offered or withheld — it is never "shown but unselectable"
* (#8201 — boundary, not gap)
*
* There is no `disabled`, `readonly` or `readonlyWhen` on a select option, and
* that is a **deliberate boundary** rather than a slot nobody added. It is the
* 2026-08-12 #7887 ruling reaching its third shape, on that ruling's own
* premise re-measured for this one: nothing in the object-field pipeline these
* options feed reads a per-option enabled/disabled flag — objectui's select and
* radio widgets treat the FIELD-level state as the single authority — so
* declaring one here would ship the ADR-0049 declared-but-unenforced shape.
* (A shown-but-unselectable option does exist in objectui's SDUI component
* family, but on that package's own option vocabulary, not this shape.)
*
* Writing one anyway stays a loud parse error — unchanged — and since #8201
* that error carries {@link SELECT_OPTION_EDITABILITY_GUIDANCE}, which points
* at the two things that are real: {@link SelectOptionSchema.visibleWhen} to
* withdraw THIS option (per record or, uniquely on this surface, per
* `current_user` — ADR-0068), and `readonly` / `readonlyWhen` on the FIELD to
* freeze the whole picker.
*
* If a non-selectable field option ever earns a real reader, that is a spec
* decision that widens the accepted set — this boundary records what the
* platform honours today, not a claim that the answer can never change.
*/
export const SelectOptionSchema = lazySchema(() => strictObject({
surface: 'this select option',
history: FIELD_HISTORY,
aliases: { text: 'label', name: 'label', title: 'label', key: 'value', id: 'value', isDefault: 'default', selected: 'default', colour: 'color', visible: 'visibleWhen', showWhen: 'visibleWhen' },
// #8201. No alias row for the editability family, per the same red line the
// mother ruling drew: an alias names a key the shape must then accept, and
// this shape accepts none of them. The set consumes those spellings before
// the rename channel runs, and none of the alias keys above is a member, so
// no existing pointer is shadowed (`alias-integrity.test.ts`, #7889).
guidanceSets: [SELECT_OPTION_EDITABILITY_GUIDANCE],
}, {
label: z.string().describe('Display label (human-readable, any case allowed)'),
value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)'),
/**
* Optional secondary text for the option (objectui#6153, inheriting the
* objectui#6140 ruling frame — maintainer 2026-08-25: a key that is
* genuinely consumed gets declared). Consumed-but-undeclared until now:
* objectui's `LookupField` takes a lookup's authored static `options` as its
* own open local type and SEARCHES this key (`opt.description &&
* opt.description.toLowerCase().includes(q)`), and its `recordToOption`
* produces the same key for fetched options — while this strict shape
* refused it at publish, so the search behaviour was real for a key no
* author could legally write. The object-definition authoring form
* (`object.form.ts` options repeater) has offered a `description` input all
* along; this declaration is what makes that offer honest. Per the same
* inherited ruling this option shape declares `description` and no cascade
* key of its own — `field-rows-option-description.test.ts` pins that
* refusal. The cascade key this package declares is the camelCase
* `dependsOn`, the `FieldSchema` member below; objectui mirrors that
* spelling on its own metadata type (maintainer ruling 2026-09-02 on
* objectui#6153).
*/
description: z.string().optional().describe('Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text.'),
color: z.string().optional().describe('Color code for badges/charts'),
default: z.boolean().optional().describe('Is default option'),
/**
* Per-option visibility predicate (CEL) — the option is offered only when this
* evaluates TRUE. Omit = always available. Evaluated against the live `record`
* PLUS the host's global predicate scope, which carries `current_user` — so it
* expresses BOTH cascading/dependent options (`record.country == 'cn'`) AND
* role/context gating (`'admin' in current_user.positions`).
*
* Options resolve through `resolveCascadingOptions` against that scope
* (ADR-0068 / objectui#2284), while field- and section-level rules go through
* `evalFieldPredicate` — a different evaluator, but since objectui#6010 (field)
* and objectui#6110 + #6111 (section) it is handed the same host scope, so
* `current_user` resolves on those surfaces too. What still separates this one
* is ENFORCEMENT, not vocabulary: per-option `visibleWhen` is the only
* VISIBILITY predicate the SERVER also evaluates — the rule validator refuses
* a write of a value whose predicate is false — while a field or section
* predicate is a rendering rule and nothing more. So a user-gated CHOICE
* belongs here; a user-gated FIELD belongs on a permission set. When the
* predicate references sibling fields, declare those on the field's `dependsOn`
* so the form can gate and re-evaluate the option list as the parent changes.
*
* ⚠️ Client-side hiding is UX, not authorization. When an option is gated for
* access-control reasons the server MUST also reject writes of its value (the
* rule-validator evaluates the picked value's `visibleWhen`) — hiding it in the
* dropdown alone is bypassable.
*/
visibleWhen: ExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`"),
}));
/**
* Location Coordinates Schema
* GPS coordinates for location field type
*
* @deprecated Never consumed by the runtime, and its key names contradict what
* the platform actually stores: a `location` value is `{lat, lng}` (see the
* field-zoo round-trip oracle), not `{latitude, longitude}`. Use
* `LocationValueSchema` / `valueSchemaFor` from `field-value.zod.ts`
* (ADR-0104 D1). Removal rides the next spec major.
*/
export const LocationCoordinatesSchema = lazySchema(() => z.object({
latitude: z.number().min(-90).max(90).describe('Latitude coordinate'),
longitude: z.number().min(-180).max(180).describe('Longitude coordinate'),
altitude: z.number().optional().describe('Altitude in meters'),
accuracy: z.number().optional().describe('Accuracy in meters'),
}));
/**
* Currency Configuration Schema
* Configuration for currency field type supporting multi-currency
*
* Note: Currency codes are validated by length only (3 characters) to support:
* - Standard ISO 4217 codes (USD, EUR, CNY, etc.)
* - Cryptocurrency codes (BTC, ETH, etc.)
* - Custom business-specific codes
* Stricter validation can be implemented at the application layer based on business requirements.
*/
export const CurrencyConfigSchema = lazySchema(() => strictObject({
surface: 'this currency configuration',
history: FIELD_HISTORY,
aliases: { decimals: 'precision', scale: 'precision', mode: 'currencyMode', currency: 'defaultCurrency', code: 'defaultCurrency', isoCode: 'defaultCurrency' },
}, {
/**
* #7918 — `.default(2)` moved off this property and into the `.overwrite()`
* below, and this placement is load-bearing. A property-level default
* materializes AT PARSE, so a refinement over the parsed object cannot tell
* an authored `precision: 2` from an untouched one — and a rule firing on
* the baked default would refuse every untouched JPY currencyConfig (the
* permanently-noisy shape the ruling forbids). Declared `.optional()`, the
* authored-vs-absent distinction survives to the `.superRefine` below;
* the `.overwrite` then materializes the same `2` AFTER the check, so parse
* OUTPUT is byte-identical to the `.default(2)` era. The `default: 2`
* annotation states the contract default to schema consumers without
* touching parse order — the `autonumberFormat` pattern below.
*/
precision: z.number().int().min(0).max(10).optional().meta({
description: 'Decimal precision (default: 2)',
default: 2,
}),
currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode: dynamic (user selectable) or fixed (single currency)'),
defaultCurrency: z.string().length(3).default('CNY').describe('Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)'),
}).superRefine((config, ctx) => {
// #7918 (maintainer ruling 2026-08-12, Option A): an AUTHORED `precision`
// that contradicts the statically-known currency's ISO 4217 / CLDR fraction
// digits is a publish-time error — `precision: 2` on a fixed-JPY config asks
// for two digits of a minor unit the yen does not have; `precision: 2` on
// fixed-KWD silently drops the third fils digit that exists.
//
// Deliberately partial, per the ruling: only `currencyMode: 'fixed'` pins a
// single currency to check against — `dynamic` mode is out of reach BY
// DESIGN (do not "improve" it), and codes outside CLDR `currencyData`
// (crypto/custom) fail OPEN. `config.precision` here is pre-`.overwrite`,
// so `undefined` means "not authored" — the defaulted 2 on an untouched
// fixed-JPY config never fires. `defaultCurrency` and `currencyMode` keep
// their property defaults: in authored-`fixed` mode the (possibly defaulted)
// `defaultCurrency` IS the field's one currency, so an authored `precision`
// contradicting it is judged even when the code itself was defaulted.
if (config.precision === undefined || config.currencyMode !== 'fixed') return;
const contradiction = currencyPrecisionContradiction(config.defaultCurrency, config.precision);
if (contradiction !== undefined) {
ctx.addIssue({ code: 'custom', path: ['precision'], message: contradiction });
}
}).overwrite((config) => {
// #7918 — the relocated `.default(2)`, applied AFTER the check above.
// `.overwrite()` rather than `.transform()` per the measured #6926 precedent
// (view.zod.ts `foldFormGroupsIntoSections`): it keeps this schema a
// `ZodObject` (a pipe has no `.extend` and answers shape introspection with
// an empty set), and checks run in attachment order, so the superRefine
// above always sees the pre-materialized value. Rebuilt in shape order so
// the output is byte-identical to the `.default(2)` era:
// `{precision, currencyMode, defaultCurrency}`, `precision` always a number
// — except on the guarded combination below. The one accepted cost, same as
// #6926's: the INFERRED output type still declares `precision?` even though
// a parsed config normally carries it (ADR-0122 forbids hand-narrowing
// `CurrencyConfigParsed`); the runtime contract is the enforced one.
//
// #11423 (maintainer ruling on #9689, 2026-08-24, routed to this twin —
// 「The same principle prescribes the fix for the #7918 currency twin
// (#11423) — the spec seat should route it under this ruling.」): NEVER
// materialize a default the schema itself would refuse as authored. The
// superRefine above rejects an AUTHORED `precision: 2` on a fixed
// zero-/three-fraction-digit currency (JPY/KRW/KWD class), and the two
// spellings are indistinguishable to any later parse BY DESIGN — so baking
// `2` onto a bare fixed-JPY config made parse output self-rejecting on
// re-parse, and `ObjectSchema.create()` → `defineStack` re-parses on the
// MAINLINE app-build path (measured: `parse(parse(x))` threw at
// `currencyConfig.precision` for accepted x). A bare fixed config whose
// currency contradicts the default 2 therefore parses to output that OMITS
// `precision`: renderers already derive display width from the currency
// when the key is absent, and built artifacts stop carrying a value the
// schema itself refuses. Every other combination keeps byte-identity —
// `dynamic` mode and unknown codes (fail-open table) can never be refused,
// so they keep materializing. The #9689 master_detail `deleteBehavior`
// conditional in `FieldSchema`'s `.overwrite()` below is the worked
// precedent; #11423 is its recorded currency twin.
if (
config.precision === undefined &&
config.currencyMode === 'fixed' &&
currencyPrecisionContradiction(config.defaultCurrency, 2) !== undefined
) {
return config;
}
return {
precision: config.precision ?? 2,
currencyMode: config.currencyMode,
defaultCurrency: config.defaultCurrency,
};
}));
/**
* Currency Value Schema
* Runtime value structure for currency fields
*
* Note: Currency codes are validated by length only (3 characters) to support flexibility.
* See CurrencyConfigSchema for details on currency code validation strategy.
*
* @deprecated This shape was never consumed and contradicts the actual runtime
* contract: a `currency` field's value is a BARE NUMBER everywhere (validator,
* SQL driver `float` column, import coercion, field-zoo oracle); the currency
* code lives in field config (`CurrencyConfigSchema`), not per value. Use
* `valueSchemaFor` from `field-value.zod.ts` (ADR-0104 D1). Removal rides the
* next spec major.
*/
export const CurrencyValueSchema = lazySchema(() => z.object({
value: z.number().describe('Monetary amount'),
currency: z.string().length(3).describe('Currency code (ISO 4217)'),
}));
/**
* Address Schema — structured address for the `address` field type.
*
* DECLARED in `./field-value.zod` since #7127 (it IS the enforced address
* VALUE contract, ADR-0104 D1) and re-exported here for compatibility. The
* move is what lets THIS file import the value-contract module for its
* `defaultValue` gate without closing a runtime ESM cycle: `field-value.zod`
* dereferenced `AddressSchema` at module-eval time, and that top-level read
* was the one runtime edge back into this file (its remaining `FieldType`
* import is type-only, erased at runtime).
*/
export { AddressSchema };
/**
* Field Schema - Best Practice Enterprise Pattern
*/
/**
* Field Definition Schema
* Defines the properties, type, and behavior of a single field (column) on an object.
*
* @example Lookup Field
* {
* name: "account_id",
* label: "Account",
* type: "lookup",
* reference: "accounts",
* required: true
* }
*
* @example Select Field
* {
* name: "status",
* label: "Status",
* type: "select",
* options: [
* { label: "Open", value: "open" },
* { label: "Closed", value: "closed" }
* ],
* defaultValue: "open"
* }
*/
/**
* Prescriptive rejection for a mis-spelled `unique` scope (ADR-0120
* §Terminology) **on the FIELD surface**: the error must carry the vocabulary
* and, for the two predictable near-misses (`'tenant'`, `'org'`), name
* `'organization'` explicitly — a typo must be a loud, fixable parse error,
* never a silent scope change. Declared before `UniqueScopeSchema` because
* `OS_EAGER_SCHEMAS=1` evaluates the factory at module load (TDZ).
*
* ⚠️ **Field-surface only — the parenthetical below is FALSE on a declared
* index, and that is why this map is not shared.** "`'organization'` … the
* explicit spelling of true" holds here (`FieldSchema.unique`), where bare
* `true` resolves per-organization. On `IndexSchema.unique` bare `true` is the
* positional spelling of `'global'` (the #4986 trap, retired at protocol 18 by
* #5082) — so a shared message read at the one moment an author is looking for
* the accepted spelling prescribed a value that CHANGES materialization on an
* index that may already exist, which is the unannounced reinterpretation the
* #8323 ruling (maintainer, 2026-08-13) exists to prevent. `object.zod.ts`
* therefore carries its own sibling map, `declaredIndexUniqueScopeError`,
* pinned equivalent to this one on accept/reject by
* `unique-scope-message.test.ts`. Keep the two vocabularies in step; only the
* parentheticals may differ.
*
* ⚠️ **One of the two hand-written `$ZodErrorMap`s in `packages/spec`, and the
* pair stays a pair.** This docblock used to say "pattern of
* `strictCapabilitiesError`"; #6805 folded that sibling into the shared
* `strictObject` template and the pointer would have gone stale, so it is
* replaced by the reason this map is NOT following it. The fold's channel is
* `unrecognized_keys` — an unknown KEY, answered from a per-key `guidance`
* table. This map answers `invalid_union`, a VALUE-level verdict on a key the
* schema declares, which `strictObject` does not address at any level. Folding
* it would be a category error, and `alias-integrity.test.ts`'s class pin
* (`NO module outside the shared helpers writes its own unrecognized_keys
* map`) is scoped by `issue.code` precisely so this site is out of class by
* measurement rather than by an exemption — that pin reads this file as a live
* control, and the index-surface sibling is out of class by the same
* measurement rather than by an added exemption.
*/
const uniqueScopeError: z.core.$ZodErrorMap = (issue) => {
if (issue.code !== 'invalid_union') return undefined;
const input = (issue as { input?: unknown }).input;
const spelled = typeof input === 'string' ? `'${input}'` : String(input);
const nearMiss =
input === 'tenant' || input === 'org'
? ` ${spelled} is not accepted and is not an alias — the per-organization scope is spelled 'organization' (ADR-0120: "tenant" is overloaded across deployment topologies, and the platform spells the word out).`
: '';
return (
`Invalid unique scope ${spelled}. Allowed: true/false, 'organization' ` +
`(one holder per organization — the explicit spelling of true), or 'global' ` +
`(one holder across the whole installation).${nearMiss}`
);
};
/**
* Uniqueness scope for a `unique` constraint (#3696, ADR-0120 D1).
*
* The vocabulary is `boolean | 'global' | 'organization'` — the scope of a
* unique constraint is *said*, never inferred from where the declaration sits.
*
* `unique: true` on an organization-scoped object materializes as a COMPOSITE
* unique index `(organization key part, field)` — "unique within the
* organization" — matching how every other tenant-aware subsystem already
* behaves (reads are RLS-filtered, writes stamp the tenant column, and the
* autonumber sequence table is keyed by `(object, tenant_id, field, scope)` so
* each organization counts from 1). A single-column global index contradicted
* that: two organizations each issuing `PROD-00001` collided on an index
* neither of them could see, and the resulting UNIQUE violation doubled as a
* cross-tenant existence oracle (a rejected insert told org B that *somebody
* else* holds the value).
*
* `unique: 'organization'` is the EXPLICIT spelling of that same
* per-organization scope (ADR-0120 D1) — a synonym of `true` at field level,
* with identical materialization. Non-normative guidance: official examples,
* scaffolding, and generators emit `'organization'` in new code so intent is
* legible without knowing the positional default; bare `true` stays valid
* indefinitely (it has exactly one documented meaning here and no trap).
*
* `unique: 'global'` opts into installation-wide uniqueness for the genuinely
* platform-wide identifiers where it is correct: an external provider id
* (`stripe_customer_id`), a DNS hostname, a globally reserved slug, a device
* identity. Global uniqueness is the special case and has to say so.
*
* NULL-safety of the per-organization scope (ADR-0120 D3, #5030): the kernel
* injects `organization_id` unconditionally, so on single-organization stacks
* the column exists and is NULL on every row — and SQL UNIQUE is
* NULL-distinct, so a raw-column composite `(organization_id, field)` enforces
* NOTHING there. The organization key part therefore materializes NULL-safe as
* `COALESCE(organization_id, '__global__')` (driver-side, #5030): NULL-org
* rows collapse into one platform bucket, unique among themselves; non-NULL
* rows are untouched. On an object with no tenant column at all
* (`tenancy.enabled: false`) both per-organization spellings degrade to the
* listed column alone.
*
* Rejected words (ADR-0120 §Terminology): `'tenant'` and `'org'` are not
* accepted and are NOT aliases — "tenant" is overloaded across deployment
* topologies and the platform spells the noun out (`organization_id`). The
* parse error names `'organization'` so the fix ships inside the rejection.
*
* ⚠️ **This schema is the FIELD surface's.** The vocabulary above is shared
* with `IndexSchema.unique`, but the *meaning of bare `true`* is not: on a
* declared index it is the positional spelling of `'global'`, not of
* `'organization'` (the #4986 trap; #5082 retires it at protocol 18). The
* index surface therefore declares its own structurally identical union with
* its own rejection text in `object.zod.ts` — accepting and rejecting exactly
* what this one does, pinned by `unique-scope-message.test.ts`. Widening or
* narrowing the member list here is a change to BOTH surfaces: make it in both
* places or the pin fails.
*/
export const UniqueScopeSchema = lazySchema(() =>
z.union([z.boolean(), z.literal('global'), z.literal('organization')], {
error: uniqueScopeError,
}),
);
/** @see UniqueScopeSchema */
export type UniqueScope = boolean | 'global' | 'organization';
/**
* Does this `unique` declaration ask for platform-wide (cross-tenant)
* uniqueness? Single source of truth for every driver that materializes a
* unique constraint (SQL DDL, Mongo index sync) so they cannot drift.
*/
export function isGlobalUnique(unique: unknown): boolean {
return unique === 'global';
}
/**
* Does this `unique` declaration ask for a unique constraint at all?
* `true`, `'global'` and `'organization'` do; `false`/absent do not.
* `'organization'` counts from the moment the word exists (ADR-0120 D1) —
* a scope the vocabulary accepts but no driver reads would be
* declarable-but-inert, the exact ADR-0078 class this vocabulary closes.
*/
export function isUniqueDeclared(unique: unknown): boolean {
return unique === true || unique === 'global' || unique === 'organization';
}
/**
* Is this the EXPLICIT `'organization'` spelling (ADR-0120 D1)?
*
* Deliberately narrow — it detects the word, not the scope. At field level,
* bare `true` also means per-organization (the positional default;
* `isUniqueDeclared(u) && !isGlobalUnique(u)` is that question), so field
* consumers need no new predicate. This helper exists for the DECLARED-index
* side, where the two spellings differ: `'organization'` asks the driver to
* prepend the NULL-safe organization key part at registration, while bare
* `true` stays verbatim (deprecated spelling of `'global'` — warned in 17.x,
* rejected at protocol 18). Single source of truth so SQL and Mongo index
* sync cannot drift on the distinction.
*/
export function isOrganizationUnique(unique: unknown): boolean {
return unique === 'organization';
}
/**
* Partial-masking presets (#8993, maintainer ruling 2026-08-16, Option A).
*
* A CLOSED enum, deliberately: free-form format strings are exactly where
* AI-authored metadata errors hide (an unparseable format silently degrades or
* silently over-reveals), so the vocabulary is named presets plus one
* keep-head/keep-tail escape hatch — no per-role rule matrices, no template
* strings. Each preset is a deterministic, length-preserving transform
* implemented by `@objectstack/plugin-security`'s `maskFieldValue`
* (`field-masker.ts` — the single enforcement channel):
*
* - `phone` — keep first 3 + last 4 (`138****5678`)
* - `id_card` — keep first 6 + last 4 (`110101********1234`)
* - `bank_account` — keep last 4 only (`************1234`)
* - `email` — keep the local part's first character + the full domain
* (`j***@example.com`)
* - `name` — keep the first character (`张**`)
*/
export const FIELD_MASKING_PRESETS = ['phone', 'id_card', 'bank_account', 'email', 'name'] as const;
/** @see FIELD_MASKING_PRESETS */
export type FieldMaskingPreset = (typeof FIELD_MASKING_PRESETS)[number];
/**
* The keep-head/keep-tail escape hatch for the long tail of business formats
* the presets do not name (an employee id, a license plate, a policy number).
* Keeps the first `keepHead` and last `keepTail` characters and masks
* everything between with `*`; a value too short to keep both ends is masked
* entirely (the safe direction — degrade toward MORE masking, never less).
* `{ keepHead: 0, keepTail: 0 }` is legal and masks the whole value.
*/
export const FieldMaskingKeepSchema = lazySchema(() => strictObject({
surface: 'this masking rule',
history: FIELD_HISTORY,
aliases: { head: 'keepHead', prefix: 'keepHead', keepStart: 'keepHead', tail: 'keepTail', suffix: 'keepTail', keepEnd: 'keepTail' },
}, {
keepHead: z.number().int().min(0).describe('Number of leading characters to leave readable'),
keepTail: z.number().int().min(0).describe('Number of trailing characters to leave readable'),
}));
/** @see FieldMaskingKeepSchema (ADR-0122: bare alias = the AUTHOR state; input and parsed coincide here — no transform, no defaults) */
export type FieldMaskingKeep = z.input<typeof FieldMaskingKeepSchema>;
/**
* A field's declared partial-masking rule — a named preset or the
* keep-head/keep-tail form. See the `maskingRule` key on {@link FieldSchema}
* for the enforcement contract.
*/
export const FieldMaskingRuleSchema = lazySchema(() => z.union([
z.enum(FIELD_MASKING_PRESETS),
FieldMaskingKeepSchema,
]));
/** @see FieldMaskingRuleSchema */
export type FieldMaskingRule = FieldMaskingPreset | { keepHead: number; keepTail: number };
/**
* `FIELD_KEY_GUIDANCE`, re-expressed as `strictObject` options.
*
* That table is the curated list of near-misses and retirements on this exact
* surface — twenty-odd entries, every one found in the wild, and already held
* honest by `authoring-key-lint.test.ts` (every `to` must name a key this schema
* really declares; no entry may exist for a key that is still live). Copying it
* here would have made a second copy of the truth, which is the thing this
* campaign keeps finding rotted.
*
* It also carries knowledge the fallback cannot rederive, and the proof is in
* that file: `pii` is three edits from `min`, so an edit-distance suggester
* offers "did you mean `min`?" — confident, wrong, and about an unrelated
* concept. The lint suppressed that years ago. Closing this shape without
* reusing the table reintroduced it verbatim, which is how this wiring got
* written.
*
* `to` becomes an alias (the concept survives under another key); `why` becomes
* guidance (a retirement with no successor, which also suppresses the rename).
* The table's consumer changes here — the lint no longer reaches `field` now
* that the parse rejects first — but the table itself is unchanged and still
* tested.
*/
function fieldKeyGuidanceAsStrictOptions() {
const aliases: Record<string, string> = {};
const guidance: Record<string, string> = {};
for (const [key, hint] of Object.entries(FIELD_KEY_GUIDANCE)) {
if (hint.to) aliases[key] = hint.to;
else if (hint.why) guidance[key] = hint.why;
}
return { aliases, guidance };
}
/**
* What `z.array(z.any())` cost on the two explicit column lists below (#9227):
* every column object validated — right keys, wrong keys, misspelled keys,
* empty objects — so a mis-keyed column published clean and surfaced only in
* the browser, as a grid with the right row COUNT and every cell blank
* (objectui#3951 measured exactly this failure one seam over, in the
* renderer). A lenient producer schema is where AI-generated metadata errors
* hide; these shapes close it at publish time.
*/
const INLINE_GRID_COLUMN_HISTORY =
'Until this shape was closed these parsed as `z.any()` — a mis-keyed column published '
+ 'clean and rendered as blank cells, with nothing naming the wrong key.';
/**
* One explicit column of the inline master-detail grid (`inlineColumns`).
*
* STRICT mirror of the objectui inline-grid renderer's `GridColumn`
* (`packages/fields/src/widgets/GridField.tsx`, hydration in
* `packages/plugin-form/src/deriveMasterDetail.ts`) — the objectui#3951
* `name`-keyed contract. Admits exactly the keys that renderer has a live
* read for (measured against objectui main, 2026-08-17); an unknown key is a
* named rejection at publish time, and the retired `field` spelling is
* refused with a prescription naming `name`.
*
* The minimal — and recommended — authored entry is identity-only
* (`{ name: 'quantity' }`): when a column declares no `type`, objectui's
* `hydrateColumns` fills `label`, `type`, `options`, the lookup target and
* conditional rules, and the computed expression from the child object's own
* field definitions, so the columns cannot drift from the fields they show.
* Declaring a `type` opts that column out of hydration entirely — supply the
* extras it needs (options / reference / …) yourself.
*/
export const InlineGridColumnSchema = lazySchema(() => strictObject({
surface: 'this inline grid column',
history: INLINE_GRID_COLUMN_HISTORY,
aliases: {
// The retired grid spelling: objectui#3951 aligned the widget to `name`
// (the FORM-layer identity key), with deliberately no tolerant alias in
// the renderer — the refusal here is the producer-side half of that.
field: 'name', fieldName: 'name', key: 'name',
title: 'label', header: 'label',
size: 'width',
// The field-level formula key; a grid column's computed cell reads the
// bare arithmetic `expr` (paired with `computed`), never a CEL envelope.
expression: 'expr',
hidden: 'defaultHidden',
},
}, {
name: z.string().min(1).describe('Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name). The retired `field` spelling is refused.'),
label: z.string().optional().describe("Column header; defaults to the child field's label via hydration."),
type: z.enum(['text', 'number', 'currency', 'date', 'datetime', 'time', 'select', 'lookup', 'file']).optional().describe("Cell control, derived from the child field's type when omitted. Declaring it opts the column out of schema hydration — supply the extras (options / reference / …) yourself."),
width: z.number().positive().optional().describe('Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed).'),
required: z.boolean().optional().describe('Cell is flagged inline-invalid while empty. Computed columns are never required.'),
options: z.array(strictObject({
surface: 'this inline grid column option',
history: INLINE_GRID_COLUMN_HISTORY,
aliases: { text: 'label', name: 'label', title: 'label', key: 'value', id: 'value' },
}, {
label: z.string().describe('Option label shown in the select cell.'),
value: z.string().min(1).describe("Stored option value; must match the child select field's option values."),
})).optional().describe("Select-cell options for `type: 'select'`; derived from the child field's options when the column declares no `type`."),
prefix: z.string().optional().describe("Currency symbol rendered inside a `currency` cell (default '¥')."),
step: z.number().positive().optional().describe('Input step for numeric cells.'),
reference: z.string().optional().describe("Referenced object for `type: 'lookup'` cells; derived from the child lookup field when the column declares no `type`."),
displayField: z.string().optional().describe('Label field shown for a picked lookup record.'),
idField: z.string().optional().describe('Id field stored for a picked lookup record.'),
multiple: z.boolean().optional().describe('Multi-value column: multi-record lookup, or multi-file upload cell.'),
accept: z.array(z.string()).optional().describe("Accepted MIME types / extensions for a `file` cell's picker (e.g. ['image/*', '.pdf']); omit to accept anything."),
defaultHidden: z.boolean().optional().describe("Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden."),
computed: z.boolean().optional().describe('Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row.'),
expr: z.string().min(1).optional().describe("Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here."),
scale: z.number().int().nonnegative().optional().describe('Decimal places to round a computed numeric/currency result to.'),
autofill: z.boolean().optional().describe("For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable."),
readonlyWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`)."),
requiredWhen: ExpressionInputSchema.optional().describe('Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD\'s own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.'),
}));
export const FieldSchema = lazySchema(() => {
const base = strictObject({
surface: 'this field',
history: FIELD_HISTORY,
aliases: {
...fieldKeyGuidanceAsStrictOptions().aliases,
fieldName: 'name', key: 'name', column: 'name',
dataType: 'type', fieldType: 'type',
title: 'label', displayName: 'label',
help: 'inlineHelpText', helpText: 'inlineHelpText', hint: 'inlineHelpText', tooltip: 'inlineHelpText',
default: 'defaultValue', initialValue: 'defaultValue',
isRequired: 'required', mandatory: 'required', notNull: 'required',
isUnique: 'unique',
values: 'options', choices: 'options', picklist: 'options', selectOptions: 'options',
relatedTo: 'reference', referenceTo: 'reference', target: 'reference', targetObject: 'reference', lookupObject: 'reference',
onDelete: 'deleteBehavior', deleteRule: 'deleteBehavior', cascade: 'deleteBehavior',
formula: 'expression', calculation: 'expression', compute: 'expression',
// `rollup` alone covers `rollUp` / `roll_up` / `Roll-Up` — `aliasProbe`
// folds case and separators, so a second spelling was never reachable
// (#5481).
rollup: 'summaryOperations', summary: 'summaryOperations', aggregate: 'summaryOperations',
length: 'maxLength', size: 'maxLength',
decimals: 'scale', decimalPlaces: 'scale', digits: 'precision',
isReadonly: 'readonly', disabled: 'readonly',
isHidden: 'hidden', invisible: 'hidden',
// `showWhen` has only one reading — a predicate — so it renames. Its
// sibling `visible` has two on this surface and is answered in prose
// below; `disabled` already renames onto `readonly` above, which is the
// right target here because a field has `readonlyWhen`, not `disabledWhen`
// (#7832).
showWhen: 'visibleWhen',
section: 'group', category: 'group', fieldset: 'group',
component: 'widget', renderer: 'widget', control: 'widget',
mimeTypes: 'accept', allowedTypes: 'accept', fileTypes: 'accept',
maxFileSize: 'maxSize', maxBytes: 'maxSize',
trackChanges: 'trackHistory', feedTracked: 'trackHistory',
permissions: 'requiredPermissions', requiredCapabilities: 'requiredPermissions',
},
guidance: {
...fieldKeyGuidanceAsStrictOptions().guidance,
// Entries the lint's table does not carry, because the lint never had to:
// these are the removals recorded only as comments on this object, and a
// comment is visible to everyone except the author who got it wrong.
columnName:
'`columnName` was removed in the 16.x line — the SQL driver hardcodes the physical '
+ 'column to the field key, so a custom name was ignored. External/federated objects map '
+ 'physical columns with `external.columnMap` (ADR-0062 D7).',
// `currency` is not, and has never been, a declared FieldSchema key — it is
// not a retirement, just a natural spelling with no landing key of its own
// (#8163). Prose rather than an `aliases` rename because the target is a
// NESTED key: `currencyConfig.defaultCurrency` under `currencyMode: 'fixed'`,
// which a flat rename cannot express. The spelling is not hypothetical —
// objectui's `resolveFieldCurrency` reads `field.currency` first from looser
// grid/column configs, so it circulates in configs an AI author will have
// seen.
currency:
'`currency` is not a field key — a fixed currency is declared as `currencyConfig: '
+ '{ currencyMode: \'fixed\', defaultCurrency: \'JPY\' }`. A field without one uses '
+ 'the tenant default at runtime.',
referenceFilters:
'`referenceFilters` (string[]) was removed in the 16.x line — the lookup picker only '
+ 'ever read the structured form. Use `lookupFilters: [{ field, operator, value }]`.',
// `notNull` is aliased to `required` above for the common case, but ADR-0113
// makes the two deliberately distinct and the distinction IS the point, so
// the flattened spelling gets its own sentence rather than a rename.
storageNotNull:
'physical column constraints live under `storage` — write `storage: { notNull: true }` '
+ '(ADR-0113). `required` is the WRITE contract and deliberately does not imply the column '
+ 'constraint.',
tracked: '`tracked` is not a field key — per-field timeline tracking is `trackHistory: true` (ADR-0052 §5b).',
// Prose rather than a rename, because this surface declares BOTH forms and
// the two answers have opposite polarity: renaming onto `visibleWhen` sends
// `visible: false` to a slot that wants a CEL string, and renaming onto
// `hidden` silently inverts the value the author already wrote. Naming both
// is the only answer that cannot be acted on wrongly (#7832 / #7816).
visible:
'`visible` is not a field key, and which key you want depends on the form: a static '
+ 'boolean is `hidden` — INVERTED, so `visible: false` is `hidden: true` — while a '
+ 'per-record CEL predicate is `visibleWhen` (shown only when TRUE). Its siblings are '
+ '`readonlyWhen` and `requiredWhen`.',
},
}, {
/** Identity */
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Machine name (snake_case)').optional(),
label: z.string().optional().describe('Human readable label'),
type: FieldType.describe('Field Data Type'),
description: z.string().optional().describe('Tooltip/Help text'),
format: z.string().optional().describe('Format string (e.g. email, phone)'),
// `columnName` removed in the 16.x line (#2377, ADR-0049): the SQL driver
// hardcodes the physical column = field key (createColumn never reads it), so
// a custom column name was silently ignored. External/federated objects map
// physical columns via `external.columnMap` (ADR-0062 D7 / ADR-0015).
/**
* Write contract (ADR-0113 — NOT a column constraint; see `storage.notNull`).
*
* On a multi-value lookup (`multiple: true`), `required` means NON-EMPTY
* array: an emptied required set fails validation loudly — `[]` does not
* satisfy `required` (#9447, maintainer ruling 2026-08-18). The empty set is
* always representable (it reads back as `[]`, never `null` — see
* `multiple`), so the required check judges emptiness, not absence.
*/
required: z.boolean().default(false).describe('Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field.'),
/**
* Physical storage constraints (ADR-0113). Deliberately separate from the
* write contract above: `required` governs what a WRITE must provide;
* `storage` governs what the COLUMN enforces. All four combinations are
* legitimate — `required` alone is the criteria_json posture (legacy null
* rows rest), `storage.notNull` alone is the engine-populated column
* (audit fields, the tenant column). Declaring `notNull` over existing
* null rows is a destructive migration gated by the schema-drift ceremony
* (backfill first); a column STRICTER than its declaration is reported as
* informational, never as actionable drift.
*/
storage: z.object({
notNull: z.boolean().optional().describe('Emit a physical NOT NULL on the column (ADR-0113). Absent = the column stays nullable even under `required: true` — the write contract is enforced at the engine, the only sanctioned write path, not by the database. Declaring this over existing null rows is a destructive migration gated by the schema-drift ceremony. Incompatible with `requiredWhen` (a conditional contract cannot be an unconditional column constraint).'),
}).strict().optional().describe('Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested.'),
searchable: z.boolean().default(false).describe('Is searchable'),
/**
* Multi-value empty representation (#9447, maintainer ruling 2026-08-18):
* an emptied multi-value lookup reads back as `[]`, never `null`. This
* binds the field's empty representation for EVERY writer — cascade repair
* (`set_null` member removal), form clears, API writes — not as a
* cascade-only convention: an array field always reads as an array, so
* readers (generated code, formula/filter predicates) never need a null
* branch. Same ruling: `required` on a multi-value lookup means non-empty
* array (see `required` above).
*/
multiple: z.boolean().default(false).describe('Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18).'),
// `true` = unique WITHIN the tenant on a tenant-scoped object (composite
// `(tenantField, field)` index); `'global'` = platform-wide single-column
// unique. See {@link UniqueScopeSchema} for the scope vocabulary (ADR-0120).
//
// [#13894] No key-level `.default()` here, on purpose: the default is
// TYPE-CONDITIONAL — `autonumber` ⇒ `'organization'`, every other type ⇒
// `false` — and a key-level default can neither see `type` nor tell an
// omitted key from an authored `false` (the opt-out spelling). It is
// materialized by the `.overwrite()` tail of this schema, at this shape
// position, so parse output for every non-autonumber type is byte-identical
// to the `.default(false)` era. The JSON Schema therefore carries NO
// `default` annotation (a single value would be wrong for one of the two
// cases) — the description states the rule.
unique: UniqueScopeSchema.optional().describe("Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier."),
defaultValue: z.unknown().optional().describe('Default applied on INSERT when the field is omitted or null (`\'\'` is a real value, not absence). Three legal shapes, discriminated in the engine\'s own order: a CEL Expression envelope `{ dialect: \'cel\', source: \'today()\' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: \'sys_user\'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field\'s own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message.'),
/** Text/String Constraints */
// #11566 — a character length is a positive integer, so `0`, `-5` and `12.5`
// are refused at the producer (house pattern: the #8321 `precision`/`scale`
// refusal below; same "a malformed count has no defined meaning" argument —