@wildo-ai/zod-decorators 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/esm/currencies.shared.schemas.d.ts.map +1 -1
  2. package/dist/esm/currencies.shared.schemas.js.map +1 -1
  3. package/dist/esm/files/agent-file-formats.d.ts +51 -37
  4. package/dist/esm/files/agent-file-formats.d.ts.map +1 -1
  5. package/dist/esm/files/agent-file-formats.js +54 -47
  6. package/dist/esm/files/agent-file-formats.js.map +1 -1
  7. package/dist/esm/files/extension-nature-map.d.ts.map +1 -1
  8. package/dist/esm/files/mime-registry.d.ts.map +1 -1
  9. package/dist/esm/files/unified-registry.d.ts +2 -2
  10. package/dist/esm/files/unified-registry.d.ts.map +1 -1
  11. package/dist/esm/files/unified-registry.js +1 -1
  12. package/dist/esm/files/unified-registry.js.map +1 -1
  13. package/dist/esm/index.d.ts +1 -0
  14. package/dist/esm/index.d.ts.map +1 -1
  15. package/dist/esm/index.js +1 -0
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/testing/decorated-test-schemas.d.ts.map +1 -1
  18. package/dist/esm/testing.d.ts +11 -0
  19. package/dist/esm/testing.d.ts.map +1 -1
  20. package/dist/esm/testing.js +11 -0
  21. package/dist/esm/testing.js.map +1 -1
  22. package/dist/esm/zod-address.d.ts +146 -0
  23. package/dist/esm/zod-address.d.ts.map +1 -0
  24. package/dist/esm/zod-address.js +176 -0
  25. package/dist/esm/zod-address.js.map +1 -0
  26. package/dist/esm/zod-bigdecimal.d.ts.map +1 -1
  27. package/dist/esm/zod-code.d.ts +2 -0
  28. package/dist/esm/zod-code.d.ts.map +1 -1
  29. package/dist/esm/zod-code.js +4 -4
  30. package/dist/esm/zod-code.js.map +1 -1
  31. package/dist/esm/zod-color.d.ts.map +1 -1
  32. package/dist/esm/zod-decorators.types.d.ts +311 -29
  33. package/dist/esm/zod-decorators.types.d.ts.map +1 -1
  34. package/dist/esm/zod-decorators.types.js +177 -8
  35. package/dist/esm/zod-decorators.types.js.map +1 -1
  36. package/dist/esm/zod-duration.d.ts +2 -0
  37. package/dist/esm/zod-duration.d.ts.map +1 -1
  38. package/dist/esm/zod-duration.js +4 -4
  39. package/dist/esm/zod-duration.js.map +1 -1
  40. package/dist/esm/zod-file.d.ts +70 -4
  41. package/dist/esm/zod-file.d.ts.map +1 -1
  42. package/dist/esm/zod-file.js +92 -75
  43. package/dist/esm/zod-file.js.map +1 -1
  44. package/dist/esm/zod-money.d.ts +2 -0
  45. package/dist/esm/zod-money.d.ts.map +1 -1
  46. package/dist/esm/zod-money.js +6 -6
  47. package/dist/esm/zod-money.js.map +1 -1
  48. package/dist/esm/zod-phone.d.ts +8 -0
  49. package/dist/esm/zod-phone.d.ts.map +1 -1
  50. package/dist/esm/zod-phone.js +6 -1
  51. package/dist/esm/zod-phone.js.map +1 -1
  52. package/dist/esm/zod-range.d.ts.map +1 -1
  53. package/dist/esm/zod-utils.shared.d.ts +343 -12
  54. package/dist/esm/zod-utils.shared.d.ts.map +1 -1
  55. package/dist/esm/zod-utils.shared.js +1004 -90
  56. package/dist/esm/zod-utils.shared.js.map +1 -1
  57. package/dist/esm/zod-versioned.d.ts.map +1 -1
  58. package/dist/esm/zod-versioned.js.map +1 -1
  59. package/dist/esm/zod.module-override.d.ts +29 -1
  60. package/dist/esm/zod.module-override.d.ts.map +1 -1
  61. package/dist/esm/zod.module-override.js +67 -8
  62. package/dist/esm/zod.module-override.js.map +1 -1
  63. package/dist/tsconfig.build.tsbuildinfo +1 -1
  64. package/package.json +2 -7
  65. package/dist/esm/companion-exports.d.ts +0 -8
  66. package/dist/esm/companion-exports.d.ts.map +0 -1
  67. package/dist/esm/companion-exports.js +0 -8
  68. package/dist/esm/companion-exports.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { BooleanEditMode, EnumEditMode, NumberEditMode, StringEditMode } from './zod-decorators.types.js';
3
- import { DEFAULT_FOREIGN_KEY_DISPLAY_MODE, FileGenerationSource, FieldSchemaType, FormFieldComponent, RangeType, SCHEMA_METADATA, ZodDecorator } from './zod-decorators.types.js';
2
+ import { BooleanEditMode, DisplayEmptyReason, EnumEditMode, EnumMultipleEditMode, NumberEditMode, StringEditMode } from './zod-decorators.types.js';
3
+ import { DEFAULT_FOREIGN_KEY_DISPLAY_MODE, FileGenerationSource, FieldSchemaType, FormFieldComponent, RangeType, SCHEMA_METADATA, ZodDecorator, RequestDtoWriteSemantics } from './zod-decorators.types.js';
4
4
  import { z_range } from './zod-range.js';
5
5
  import { addDecorators, getMetadataRecursive } from './zod.module-override.js';
6
6
  // Type guard to check if a schema has Zod v4 internals
@@ -97,6 +97,84 @@ export function unwrapSchema(schema) {
97
97
  return schema;
98
98
  }
99
99
  }
100
+ /**
101
+ * The same PATCH rule, one level down: inside a NESTED object field, a key the caller omits must not be
102
+ * re-materialised from its `.default()` either.
103
+ *
104
+ * `toPatchOptionalField` peels the OUTER default of a top-level field, but an object field's own keys
105
+ * kept theirs, so `{ privacy: { privacyLevel: 'minimal' } }` parsed to
106
+ * `{ privacy: { privacyLevel: 'minimal', consentedPurposes: [] } }` — a write the caller never made, which
107
+ * a downstream hook cannot tell apart from an explicit empty set (#534 review F1: a privacy-level change
108
+ * read as a consent withdrawal). Zod 4 applies a default even inside `.optional()`, so the default must
109
+ * be peeled, not wrapped.
110
+ *
111
+ * (`privacyLevel` itself was removed in #1280 — it was a stored preference nothing read. The example is
112
+ * kept in its original terms because it is the account of a defect that actually happened; the rule it
113
+ * motivates is about ANY nested key with a default, and is unchanged.)
114
+ *
115
+ * Scope, deliberately narrow:
116
+ * - nested PLAIN object fields, recursively, through `optional` / `nullable` wrappers only. An object
117
+ * nested inside a union, discriminated union, record, intersection, pipe/transform, `readonly` or `lazy`
118
+ * is NOT peeled and keeps re-materialising its defaults — narrower than it could be, never wrong;
119
+ * - an object carrying its own refinement is NOT peeled: a `.refine()` / `.superRefine()` may read a
120
+ * defaulted key, and would see `undefined` (or throw) once that default stopped applying;
121
+ * - a nested key WITH a default becomes optional without it; a nested key WITHOUT one keeps its declared
122
+ * optionality — a replaced nested object must still carry what it requires;
123
+ * - ARRAYS are left alone: an array element is sent whole, and its defaults describe a new element;
124
+ * - the object is rebuilt with Zod's own `clone(def)`, so `strict` / `catchall` and refinements survive;
125
+ * decorator metadata AND `z.globalRegistry` metadata (`.describe()`, `.meta()` — what MCP tool input
126
+ * schemas are generated from) are copied onto every rebuilt schema, and inner schemas are SHARED,
127
+ * never mutated, so the source schema is untouched.
128
+ */
129
+ function withoutNestedObjectDefaults(schema) {
130
+ const type = getZodType(schema);
131
+ if (type === 'optional' || type === 'nullable') {
132
+ const inner = getZodDefSchemaProperty(schema, 'innerType');
133
+ if (!inner)
134
+ return schema;
135
+ const rebuiltInner = withoutNestedObjectDefaults(inner);
136
+ if (rebuiltInner === inner)
137
+ return schema;
138
+ return copySchemaMetadata(schema, type === 'optional' ? rebuiltInner.optional() : rebuiltInner.nullable());
139
+ }
140
+ if (type !== 'object')
141
+ return schema;
142
+ const def = schema._zod.def;
143
+ if (Array.isArray(def.checks) && def.checks.length > 0)
144
+ return schema;
145
+ const shape = schema.shape;
146
+ const nextShape = {};
147
+ let changed = false;
148
+ for (const [key, field] of Object.entries(shape)) {
149
+ let next;
150
+ if (isZodDefault(field)) {
151
+ const peeled = getZodDefSchemaProperty(field, 'innerType');
152
+ next = peeled ? copySchemaMetadata(field, withoutNestedObjectDefaults(peeled).optional()) : field;
153
+ }
154
+ else {
155
+ next = withoutNestedObjectDefaults(field);
156
+ }
157
+ if (next !== field)
158
+ changed = true;
159
+ nextShape[key] = next;
160
+ }
161
+ if (!changed)
162
+ return schema;
163
+ const rebuilt = schema.clone({ ...def, shape: nextShape });
164
+ return copySchemaMetadata(schema, rebuilt);
165
+ }
166
+ /** Carry decorator metadata from a source wrapper onto a rebuilt one; the source is never mutated. */
167
+ function copySchemaMetadata(source, target) {
168
+ const registered = z.globalRegistry.get(source);
169
+ if (registered && !z.globalRegistry.has(target))
170
+ z.globalRegistry.add(target, registered);
171
+ const metadata = source[SCHEMA_METADATA];
172
+ if (metadata) {
173
+ const record = target;
174
+ record[SCHEMA_METADATA] = { ...(record[SCHEMA_METADATA] ?? {}), ...metadata };
175
+ }
176
+ return target;
177
+ }
100
178
  /**
101
179
  * Make a field OPTIONAL for a PATCH-style UPDATE DTO, peeling any OUTER
102
180
  * `.default()` wrapper first.
@@ -137,11 +215,13 @@ export function unwrapSchema(schema) {
137
215
  export function toPatchOptionalField(fieldSchema) {
138
216
  const admitsNullClear = fieldAdmitsAddedNullClear(fieldSchema);
139
217
  if (!isZodDefault(fieldSchema)) {
140
- return admitsNullClear ? fieldSchema.nullable().optional() : fieldSchema.optional();
218
+ const nestedPatched = withoutNestedObjectDefaults(fieldSchema);
219
+ return admitsNullClear ? nestedPatched.nullable().optional() : nestedPatched.optional();
141
220
  }
142
- const innerType = getZodDefSchemaProperty(fieldSchema, 'innerType');
143
- if (!innerType)
221
+ const rawInnerType = getZodDefSchemaProperty(fieldSchema, 'innerType');
222
+ if (!rawInnerType)
144
223
  return fieldSchema.optional();
224
+ const innerType = withoutNestedObjectDefaults(rawInnerType);
145
225
  // A defaulted field is never null-clearable (see `fieldAdmitsAddedNullClear`), so the peeled
146
226
  // inner is made optional WITHOUT a nullable wrapper.
147
227
  const optionalInner = innerType.optional();
@@ -219,6 +299,69 @@ export function normalizeClearedRequestDtoValue(fieldSchema, value) {
219
299
  export function normalizeClearedMainSchemaValue(fieldSchema, value) {
220
300
  return normalizeClearedValue(fieldSchema, value, mainSchemaFieldIsClearable);
221
301
  }
302
+ /**
303
+ * Mark an UPDATE request DTO object as written PATCH style (see `RequestDtoWriteSemantics`).
304
+ *
305
+ * The mark lives in the `SCHEMA_METADATA` bag because that bag is what every DTO rebuild carries
306
+ * across (network-boundary coercion, templatable-file stripping, nested-relationship extension). It
307
+ * is written COPY-ON-WRITE: rebuilds share one bag object between instances, so mutating it would
308
+ * mark schemas that were never PATCH roots.
309
+ */
310
+ export function markPatchRequestDtoObject(schema) {
311
+ const record = schema;
312
+ record[SCHEMA_METADATA] = { ...(record[SCHEMA_METADATA] ?? {}), requestDtoWriteSemantics: RequestDtoWriteSemantics.PATCH };
313
+ return schema;
314
+ }
315
+ /**
316
+ * Does this object follow the PATCH write rule — absent key untouched, `null` clears?
317
+ *
318
+ * Looks through optional, nullable and default wrappers. A UNION answers `true` when ANY option is a
319
+ * PATCH object: a nested child's items are a union of an update branch (PATCH) and a create branch
320
+ * (written whole), and a field cannot tell which one it is in. `true` keeps the stricter rule, under
321
+ * which a required field stays unclearable; the other answer would let one read as clearable in the
322
+ * update branch, where clearing it would be a silent no-op.
323
+ */
324
+ export function objectFollowsPatchWriteRule(schema) {
325
+ if (!schema)
326
+ return false;
327
+ const metadata = schema[SCHEMA_METADATA];
328
+ if (metadata?.requestDtoWriteSemantics === RequestDtoWriteSemantics.PATCH)
329
+ return true;
330
+ const type = getZodType(schema);
331
+ if (type === 'optional' || type === 'nullable' || type === 'default') {
332
+ const innerSchema = getZodDefSchemaProperty(schema, 'innerType');
333
+ return innerSchema ? objectFollowsPatchWriteRule(innerSchema) : false;
334
+ }
335
+ if (type === 'union') {
336
+ const options = getZodDefArrayProperty(schema, 'options');
337
+ return (options ?? []).some((option) => objectFollowsPatchWriteRule(option));
338
+ }
339
+ return false;
340
+ }
341
+ /**
342
+ * Is "clear it" meaningful for a field inside an object that is written WHOLE — an object value
343
+ * nested in a field, an array item, or a CREATE request DTO?
344
+ *
345
+ * The declaration answers it, exactly as on a main schema: absence or null allowed → clearable, required
346
+ * or defaulted → not. `requestDtoFieldIsClearable` ("accepts null") is the wrong question here: only a
347
+ * PATCH root's direct fields get a null admitted, so it reported every nested optional field
348
+ * unclearable, and an emptied nested address could never be saved (#989).
349
+ */
350
+ export function valueObjectFieldIsClearable(fieldSchema) {
351
+ return mainSchemaFieldIsClearable(fieldSchema);
352
+ }
353
+ /**
354
+ * `normalizeClearedFieldValue` for a field inside an object written WHOLE. The cleared value is ABSENCE
355
+ * (`undefined`), or `null` only for a field that accepts null.
356
+ *
357
+ * Absence, because nothing else is safe there: the persistence layer removes only TOP-LEVEL cleared
358
+ * fields, so a `null` nested in a stored object would be persisted as `null` and then fail the main
359
+ * schema's `.optional()` on the next read.
360
+ */
361
+ export function normalizeClearedValueObjectFieldValue(fieldSchema, value) {
362
+ const normalized = normalizeClearedValue(fieldSchema, value, valueObjectFieldIsClearable);
363
+ return normalized === null && !fieldSchema.safeParse(null).success ? undefined : normalized;
364
+ }
222
365
  function normalizeClearedValue(fieldSchema, value, isClearable) {
223
366
  if (!isEmptyFieldGesture(value))
224
367
  return value;
@@ -380,9 +523,14 @@ export function isZodArray(schema) {
380
523
  * validate against. `reset_to_defaults` depended on exactly such a constraint to reject a request
381
524
  * naming no preference block, and without it the route answered HTTP 200 having written nothing.
382
525
  *
383
- * Native `.clone()` re-instantiates from the schema's own definition, so checks, error maps and
384
- * metadata survive by construction rather than by a copy loop that the next Zod version could
385
- * outgrow.
526
+ * Native `.clone()` re-instantiates from the schema's own definition, so checks and error maps
527
+ * survive by construction rather than by a copy loop that the next Zod version could outgrow.
528
+ *
529
+ * `z.globalRegistry` metadata (`.describe()`, `.meta()`) does NOT come with it: Zod links a clone to
530
+ * its source, which is how a clone inherits that metadata, only when no new definition is passed,
531
+ * and this always passes one. Measured, 2026-09-26: a billing-state `invoices` array lost its
532
+ * description to date coercion, so the API reference published it undescribed (#1625). It is copied
533
+ * explicitly below, without `id`, which names one schema and which Zod refuses to register twice.
386
534
  *
387
535
  * @param arraySchema - the array being rewritten. Returned untouched when it is not an array or
388
536
  * carries no element, so callers do not need to pre-check.
@@ -396,7 +544,14 @@ export function withArrayElement(arraySchema, element) {
396
544
  if (!def) {
397
545
  return arraySchema;
398
546
  }
399
- return arraySchema.clone({ ...def, element });
547
+ const rebuilt = arraySchema.clone({ ...def, element });
548
+ const registered = z.globalRegistry.get(arraySchema);
549
+ if (registered !== undefined && !z.globalRegistry.has(rebuilt)) {
550
+ const { id: _id, ...carried } = registered;
551
+ if (Object.keys(carried).length > 0)
552
+ z.globalRegistry.add(rebuilt, carried);
553
+ }
554
+ return rebuilt;
400
555
  }
401
556
  export function isZodOptional(schema) {
402
557
  return hasZodType(schema, 'optional');
@@ -571,6 +726,11 @@ export function getSchemaType(schema) {
571
726
  if (isPhoneSchema(schema)) {
572
727
  return FieldSchemaType.PHONE;
573
728
  }
729
+ // Check for address schemas (created by z_address()) — BEFORE the generic object case, which an
730
+ // address would otherwise fall into as a plain OBJECT with no field of its own (#561).
731
+ if (isAddressSchema(schema)) {
732
+ return FieldSchemaType.ADDRESS;
733
+ }
574
734
  // Check for versioned foreign key schemas (created by z_foreignKeyVersioned())
575
735
  if (isVersionedForeignKeySchema(schema)) {
576
736
  return FieldSchemaType.VERSIONED_FOREIGN_KEY;
@@ -618,7 +778,26 @@ export function getSchemaType(schema) {
618
778
  return FieldSchemaType.UNKNOWN;
619
779
  }
620
780
  }
621
- case 'date': return FieldSchemaType.DATE;
781
+ /*
782
+ * A `z.date()` is a DAY unless it declares itself a MOMENT.
783
+ *
784
+ * In Wildo a datetime is Date-based — an ISO-8601 string is a wire format for one, never the
785
+ * model — so `ZodDate` is the carrier for both "a day" (`dueDate`) and "a point in time"
786
+ * (`remindAt`). Nothing in the schema shape distinguishes them, which left `DATETIME`
787
+ * effectively unreachable: every date classified as `DATE`, so every one of them EDITED through
788
+ * the day picker and DISPLAYED through the day formatter, and a moment silently lost its hour
789
+ * on both surfaces while `FormFieldDateTime` and `DisplayFieldDateTime` sat unreachable.
790
+ *
791
+ * The declaration is what distinguishes them, and it already exists: `dateTimeUI()` is applyable
792
+ * to `ZodDate` precisely to say "this value carries a time of day". Reading it here means the
793
+ * two renderer dispatchers — which both key on this classification alone — pick the datetime
794
+ * pair without either of them learning a new rule.
795
+ *
796
+ * Scoped to the RENDERING classification on purpose: nothing in DTO building or persistence
797
+ * branches on `DATE` vs `DATETIME` (checked), so this changes which control a field gets and
798
+ * nothing about how its value is carried or stored.
799
+ */
800
+ case 'date': return getDateTimeUI(unwrapped) !== undefined ? FieldSchemaType.DATETIME : FieldSchemaType.DATE;
622
801
  case 'object': return FieldSchemaType.OBJECT;
623
802
  case 'array': return FieldSchemaType.ARRAY;
624
803
  case 'set': return FieldSchemaType.SET;
@@ -652,6 +831,210 @@ export function getSchemaType(schema) {
652
831
  default: return FieldSchemaType.UNKNOWN;
653
832
  }
654
833
  }
834
+ /**
835
+ * Which classified schema types HOLD FIELDS — parts that are fields of the resource in their own right, each
836
+ * labelled, displayed and validated under its own dotted path (`privacy.consentedPurposes`) — as opposed to
837
+ * being ONE value whose parts belong to the value.
838
+ *
839
+ * A structured value is a Zod object too: `z_money()` is `{ amount, currency, decimals }`, `z_address()` is
840
+ * `{ line1, …, coordinates }`, every `z_range()` is `{ from, to }`. But its parts are not fields. The value's
841
+ * own component draws and names them (`LOW_LEVEL_FORM_FIELD_ADDRESS` carries `line1`, the money field reads the
842
+ * currency through `system_enums`), so the resource has exactly ONE word for the whole value — its field label —
843
+ * and no word for any part. Walking into a structured value as if it were a nested object is how the application
844
+ * labelling planner came to generate, translate and ship `fields.amount.amount`, `fields.amount.currency` and
845
+ * `fields.amount.decimals` for a money field no screen ever looked up (#1959).
846
+ *
847
+ * A `Record` over the whole enum rather than a list of either half, so a classification added tomorrow is a
848
+ * compile error here until someone decides which it is — the label walk, the labelling planner, the frontend
849
+ * label introspection and the validation summary all read this one answer through {@link isFieldContainerSchema}.
850
+ *
851
+ * `VERSIONED_OBJECT` holds fields: `versioned()` returns the application's own object with two hidden bookkeeping
852
+ * fields added, and the display renders it through the plain object branch, child field by child field.
853
+ */
854
+ const FIELD_SCHEMA_TYPE_HOLDS_FIELDS = {
855
+ [FieldSchemaType.OBJECT]: true,
856
+ [FieldSchemaType.VERSIONED_OBJECT]: true,
857
+ [FieldSchemaType.STRING]: false,
858
+ [FieldSchemaType.NUMBER]: false,
859
+ [FieldSchemaType.BIGINT]: false,
860
+ [FieldSchemaType.BOOLEAN]: false,
861
+ [FieldSchemaType.ENUM]: false,
862
+ [FieldSchemaType.DATE]: false,
863
+ /*
864
+ * An ARRAY is not a container of FIELDS: its items are rows of one schema, and a walk that labels by field
865
+ * steps through it to the item (index-free) and asks this question of the ITEM. SET, TUPLE, RECORD and MAP
866
+ * are never walked for field labels at all.
867
+ */
868
+ [FieldSchemaType.ARRAY]: false,
869
+ [FieldSchemaType.SET]: false,
870
+ [FieldSchemaType.TUPLE]: false,
871
+ [FieldSchemaType.RECORD]: false,
872
+ [FieldSchemaType.MAP]: false,
873
+ [FieldSchemaType.TIME]: false,
874
+ [FieldSchemaType.DATETIME]: false,
875
+ [FieldSchemaType.RANGE_NUMBER]: false,
876
+ [FieldSchemaType.RANGE_DATE]: false,
877
+ [FieldSchemaType.RANGE_DATETIME]: false,
878
+ [FieldSchemaType.RANGE_TIME]: false,
879
+ [FieldSchemaType.RANGE_BIGDECIMAL]: false,
880
+ [FieldSchemaType.RANGE_MONEY]: false,
881
+ [FieldSchemaType.MONEY]: false,
882
+ [FieldSchemaType.BIGDECIMAL]: false,
883
+ [FieldSchemaType.CODE]: false,
884
+ [FieldSchemaType.FILE]: false,
885
+ [FieldSchemaType.DURATION]: false,
886
+ [FieldSchemaType.PHONE]: false,
887
+ [FieldSchemaType.ADDRESS]: false,
888
+ [FieldSchemaType.VERSIONED_FOREIGN_KEY]: false,
889
+ [FieldSchemaType.UNKNOWN]: false,
890
+ };
891
+ /**
892
+ * Does this schema HOLD FIELDS of the resource — so a walk that labels by field path descends into it — rather
893
+ * than being a scalar or a structured value whose parts are its own (money, address, duration, code, file, every
894
+ * range)? See {@link FIELD_SCHEMA_TYPE_HOLDS_FIELDS} for why the two are different and why the answer is read off
895
+ * {@link getSchemaType} rather than off a list of value types.
896
+ *
897
+ * Takes the schema as declared, wrappers included: `getSchemaType` recognises every structured value through its
898
+ * wrappers, so `z_money().optional()` answers `false` like `z_money()` does.
899
+ *
900
+ * @example
901
+ * isFieldContainerSchema(z.object({ city: z.string() })); // true — `address.city` is a field
902
+ * isFieldContainerSchema(z_address()); // false — `line1` is the address's, not the resource's
903
+ */
904
+ export function isFieldContainerSchema(schema) {
905
+ return FIELD_SCHEMA_TYPE_HOLDS_FIELDS[getSchemaType(schema)];
906
+ }
907
+ // =============================================================================
908
+ // VALUE TYPE INFERENCE — the runtime twin of getSchemaType
909
+ // =============================================================================
910
+ /**
911
+ * An ISO-8601 DATETIME: a calendar date, a `T` (or the JSON-ish space form), a clock time, and an
912
+ * optional zone designator.
913
+ *
914
+ * The `T` is what makes this safe to recognise. No identifier, version, code or reference in this
915
+ * codebase's data has that shape, so a string matching it is a datetime with as much certainty as
916
+ * a declared schema would have given.
917
+ */
918
+ const ISO_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?$/;
919
+ /** An ISO-8601 CALENDAR DATE and nothing else. */
920
+ const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
921
+ /**
922
+ * Does this `YYYY-MM-DD` name a real day?
923
+ *
924
+ * `new Date('2026-02-30')` does not throw — it rolls over to 2 March — so a pattern match alone
925
+ * would accept a string that is shaped like a date and is not one. Round-tripping the parsed parts
926
+ * back out is what rejects it, and it is the difference between recognising dates and recognising
927
+ * things with four digits, a dash and some numbers.
928
+ */
929
+ function namesARealCalendarDay(year, month, day) {
930
+ const parsed = new Date(Date.UTC(year, month - 1, day));
931
+ return parsed.getUTCFullYear() === year
932
+ && parsed.getUTCMonth() === month - 1
933
+ && parsed.getUTCDate() === day;
934
+ }
935
+ /**
936
+ * WHAT IS THIS VALUE, when no schema can say?
937
+ *
938
+ * The runtime twin of {@link getSchemaType}, and it exists for exactly one caller shape: a field
939
+ * declared `z.unknown()`. That is a DELIBERATE posture — an outbound webhook's `payload`, an
940
+ * integration's passthrough blob, a stored provider response — and the framework's answer used to
941
+ * be a single one: pretty-print everything as JSON into the code viewer. Faithful, and wrong for
942
+ * the common case, because most values inside such a payload are ordinary scalars. A name rendered
943
+ * as `"Ada"` in a monospace code block, a flag as `true`, a timestamp as a quoted ISO string: each
944
+ * is the value, and none of them is how that value reads anywhere else in the application.
945
+ *
946
+ * So when the schema cannot say, ask the value — and route a recognisable primitive to the
947
+ * formatter that primitive would have had if it HAD been declared.
948
+ *
949
+ * ## `undefined` is an answer, and the important one
950
+ *
951
+ * An object, an array, `null`, a function, a symbol — anything this cannot name — returns
952
+ * `undefined`, and the caller keeps the JSON rendering. That is the case the mechanism was built
953
+ * for and it is not being taken away; what changes is that scalars stop going through it.
954
+ *
955
+ * ## What is deliberately NOT inferred
956
+ *
957
+ * - **A number is never a date.** `1789903936618` is a plausible epoch and also a plausible id,
958
+ * counter, amount or checksum, and there is nothing in the value to tell them apart. Guessing
959
+ * would silently reformat every large integer in every opaque payload as a date in 2026.
960
+ * - **A numeric STRING stays a string.** `'42'` in a passthrough payload was sent as text by
961
+ * whoever sent it; rendering it through the number formatter would apply grouping and locale
962
+ * decimals to something that may be a code, a zip or a version segment.
963
+ * - **`true`/`false` as strings stay strings**, for the same reason.
964
+ * - **A bare `YYYY-MM-DD` IS taken as a date**, which is the one judgement call here. It is the
965
+ * only shape in the list that something else could plausibly be, and the calendar round-trip
966
+ * above is what keeps it from swallowing near-misses. In payload data this shape is a date
967
+ * essentially always, and the cost of being wrong is a differently-formatted string rather than
968
+ * a lost value.
969
+ *
970
+ * ## A string that CARRIES an object or array is that object, not a string
971
+ *
972
+ * The webhook `payload` named first above reaches the display SERIALIZED — a string whose content
973
+ * is a JSON object — and `typeof` alone reads it as a string. The first version of this function
974
+ * did exactly that, and the one case the mechanism promised to keep was the one it took away: the
975
+ * delivery-log payload rendered as a single raw line in a paragraph, found by
976
+ * `browser-settings-hub-webhooks` step 6 on its first run after this landed (#1411).
977
+ *
978
+ * So a string whose trimmed text opens with `{` or `[` AND parses to an object or array answers
979
+ * `undefined`, and keeps the JSON rendering — which already unwraps a string-wrapped value
980
+ * (`DisplayFieldJson`). The opening-character check keeps the parse off ordinary text, and the parse
981
+ * keeps prose that merely starts with a bracket ("[draft] …") a string.
982
+ *
983
+ * ## An author who disagrees has the existing escape hatch
984
+ *
985
+ * `displayComponent` is resolved BEFORE this is consulted, so a field that must always render as
986
+ * JSON says so and never reaches the inference.
987
+ */
988
+ /**
989
+ * Does this string SERIALIZE an object or array? See "A string that CARRIES an object or array"
990
+ * on {@link inferFieldSchemaTypeFromValue}. A JSON scalar (`"42"`, `"true"`) deliberately does not
991
+ * count — those are the numeric and boolean strings that function keeps as strings on purpose.
992
+ */
993
+ function carriesJsonStructure(text) {
994
+ const trimmed = text.trim();
995
+ const opener = trimmed.charAt(0);
996
+ if (opener !== '{' && opener !== '[')
997
+ return false;
998
+ try {
999
+ const parsed = JSON.parse(trimmed);
1000
+ return parsed !== null && typeof parsed === 'object';
1001
+ }
1002
+ catch {
1003
+ return false;
1004
+ }
1005
+ }
1006
+ export function inferFieldSchemaTypeFromValue(value) {
1007
+ if (value instanceof Date) {
1008
+ return FieldSchemaType.DATETIME;
1009
+ }
1010
+ switch (typeof value) {
1011
+ case 'boolean':
1012
+ return FieldSchemaType.BOOLEAN;
1013
+ case 'bigint':
1014
+ return FieldSchemaType.BIGINT;
1015
+ case 'number':
1016
+ // Including NaN and the infinities, deliberately: `JSON.stringify(NaN)` is the string
1017
+ // `null`, so the JSON path would report a value that is not there. The number formatter
1018
+ // renders them as themselves.
1019
+ return FieldSchemaType.NUMBER;
1020
+ case 'string':
1021
+ break;
1022
+ default:
1023
+ return undefined;
1024
+ }
1025
+ const text = value;
1026
+ if (carriesJsonStructure(text)) {
1027
+ return undefined;
1028
+ }
1029
+ if (ISO_DATETIME_PATTERN.test(text) && !Number.isNaN(Date.parse(text))) {
1030
+ return FieldSchemaType.DATETIME;
1031
+ }
1032
+ const dateParts = ISO_DATE_PATTERN.exec(text);
1033
+ if (dateParts && namesARealCalendarDay(Number(dateParts[1]), Number(dateParts[2]), Number(dateParts[3]))) {
1034
+ return FieldSchemaType.DATE;
1035
+ }
1036
+ return FieldSchemaType.STRING;
1037
+ }
655
1038
  // =============================================================================
656
1039
  // ZOD HELPER GETTER
657
1040
  // =============================================================================
@@ -720,6 +1103,100 @@ export function getMergedObjectOrUnionShape(schema) {
720
1103
  }
721
1104
  return {};
722
1105
  }
1106
+ /**
1107
+ * The key a union of OBJECTS is discriminated by — the declared one, or the one a plain `z.union` carries
1108
+ * structurally. `undefined` when the schema is not a union, or is a plain union no key discriminates.
1109
+ *
1110
+ * ## Why a plain union needs an answer
1111
+ *
1112
+ * `z.discriminatedUnion(key, …)` records its key; `z.union([V1, V2])` does not, even when every member
1113
+ * declares the same key as a literal and those literals never collide — which is exactly how a versioned
1114
+ * record is written (`schemaVersion: z.literal(1)` / `z.literal(2)`). Such a union IS discriminated; only
1115
+ * the spelling differs. Measured 2026-09-14 on the compliance programme's `documentationRequirements`
1116
+ * items (`z.union([V1, V2])`): the display resolved no member, so every item rendered an em-dash, and the
1117
+ * label walk collected no field inside them, so `governanceScope.subjectKind` could not be worded
1118
+ * anywhere (#1022).
1119
+ *
1120
+ * The real instance is one step less regular than that: V1 predates `schemaVersion` and does not declare it
1121
+ * at all, while V2 declares `z.literal(2)`. Both are `z.strictObject`s, so V1 REJECTS any value carrying the
1122
+ * key — its absence is as decisive as a literal.
1123
+ *
1124
+ * ## The inference, and why it cannot guess
1125
+ *
1126
+ * A key discriminates a plain union only when EVERY option is an object and, for that key:
1127
+ *
1128
+ * 1. every member either declares it as a bare `z.literal`, or omits it AND is strict (`z.strictObject`,
1129
+ * whose catchall is `never`) — a non-strict member that omits the key would also accept a value carrying it;
1130
+ * 2. the declared literal values are pairwise DISJOINT across members;
1131
+ * 3. at least one member declares it, and at most ONE member omits it.
1132
+ *
1133
+ * Under those conditions a value selects at most one member — by its literal, or by carrying no value for the
1134
+ * key (the one member that omits it) — so resolving by it is exact, the same guarantee
1135
+ * `z.discriminatedUnion` enforces at construction. Keys are tried in authoring order across the members, so
1136
+ * the answer is deterministic. A union that fails every key answers `undefined`, and callers keep treating
1137
+ * it as undiscriminated.
1138
+ *
1139
+ * One function, so the label walk (`collectResourceLabelFieldEntries`), the application label deciders and
1140
+ * the display ({@link resolveDiscriminatedUnionVariant}) agree about which unions have members at all.
1141
+ */
1142
+ export function getUnionDiscriminatorKey(schema) {
1143
+ if (!isZodUnion(schema))
1144
+ return undefined;
1145
+ const declared = getZodDefStringProperty(schema, 'discriminator');
1146
+ if (declared)
1147
+ return declared;
1148
+ const options = (getZodDefArrayProperty(schema, 'options') ?? []);
1149
+ if (options.length < 2)
1150
+ return undefined;
1151
+ if (!options.every((option) => isZodTypeAny(option) && isZodObject(option)))
1152
+ return undefined;
1153
+ const members = options;
1154
+ const memberShapes = members.map((member) => getZodObjectShape(member));
1155
+ const candidateKeys = [...new Set(memberShapes.flatMap((shape) => Object.keys(shape)))];
1156
+ for (const key of candidateKeys) {
1157
+ const seenValues = new Set();
1158
+ let declaringMembers = 0;
1159
+ let omittingMembers = 0;
1160
+ const discriminates = memberShapes.every((shape, index) => {
1161
+ const keySchema = shape[key];
1162
+ if (!keySchema) {
1163
+ omittingMembers += 1;
1164
+ return omittingMembers <= 1 && isStrictZodObject(members[index]);
1165
+ }
1166
+ if (!isZodLiteral(keySchema))
1167
+ return false;
1168
+ const literalValues = getZodDefArrayProperty(keySchema, 'values') ?? [];
1169
+ if (literalValues.length === 0 || literalValues.some((literalValue) => seenValues.has(literalValue)))
1170
+ return false;
1171
+ for (const literalValue of literalValues)
1172
+ seenValues.add(literalValue);
1173
+ declaringMembers += 1;
1174
+ return true;
1175
+ });
1176
+ if (discriminates && declaringMembers > 0)
1177
+ return key;
1178
+ }
1179
+ return undefined;
1180
+ }
1181
+ /** A `z.strictObject` — its catchall is `never`, so it rejects every key its shape does not declare. */
1182
+ function isStrictZodObject(schema) {
1183
+ const catchall = getZodDefSchemaProperty(schema, 'catchall');
1184
+ return catchall !== undefined && getZodType(catchall) === 'never';
1185
+ }
1186
+ /**
1187
+ * The OBJECT members of a union that {@link getUnionDiscriminatorKey} discriminates — declared or plain —
1188
+ * in authoring order; `[]` for anything else. Pass the UNWRAPPED schema.
1189
+ *
1190
+ * For a declared discriminated union the non-object options are filtered out, as
1191
+ * `AutomatedLayoutFrontendUtils.getDiscriminatedUnionObjectOptions` does; a plain union has none (the
1192
+ * inference requires every option to be an object).
1193
+ */
1194
+ export function getUnionObjectMembers(schema) {
1195
+ if (!getUnionDiscriminatorKey(schema))
1196
+ return [];
1197
+ const options = (getZodDefArrayProperty(schema, 'options') ?? []);
1198
+ return options.filter((option) => isZodTypeAny(option) && isZodObject(option));
1199
+ }
723
1200
  /**
724
1201
  * Resolve the ONE variant of a discriminated union that a given value belongs to.
725
1202
  *
@@ -729,22 +1206,28 @@ export function getMergedObjectOrUnionShape(schema) {
729
1206
  * which variant this is, so the shape can be exactly that variant's, with no fields belonging to
730
1207
  * the alternatives the value did not choose.
731
1208
  *
1209
+ * The discriminator is {@link getUnionDiscriminatorKey}'s: declared, or carried structurally by a plain
1210
+ * `z.union` (#1022 — a versioned record's items). For such a plain union a value carrying NO discriminator
1211
+ * belongs to the one strict member that omits the key, when there is one; for a declared discriminated union
1212
+ * it belongs to none.
1213
+ *
732
1214
  * Returns `null` when the schema is not a discriminated union, when the value carries no
733
- * discriminator, or when no variant matches it — all three are "this caller cannot narrow", and a
734
- * caller that renders should say nothing rather than guess a variant.
1215
+ * discriminator its union can place, or when no variant matches it — all three are "this caller cannot
1216
+ * narrow", and a caller that renders should say nothing rather than guess a variant.
735
1217
  */
736
1218
  export function resolveDiscriminatedUnionVariant(schema, value) {
737
- if (!isDiscriminatedUnion(schema))
738
- return null;
739
1219
  if (typeof value !== 'object' || value === null)
740
1220
  return null;
741
- const discriminator = getZodDefStringProperty(schema, 'discriminator');
1221
+ const discriminator = getUnionDiscriminatorKey(schema);
742
1222
  if (!discriminator)
743
1223
  return null;
744
1224
  const discriminatorValue = value[discriminator];
745
- if (discriminatorValue === undefined)
746
- return null;
747
1225
  const options = (getZodDefArrayProperty(schema, 'options') ?? []);
1226
+ if (discriminatorValue === undefined) {
1227
+ if (getZodDefStringProperty(schema, 'discriminator'))
1228
+ return null;
1229
+ return options.find((option) => isZodObject(option) && getZodObjectShape(option)[discriminator] === undefined) ?? null;
1230
+ }
748
1231
  for (const option of options) {
749
1232
  if (!isZodObject(option))
750
1233
  continue;
@@ -786,6 +1269,33 @@ export function getZodDefArrayProperty(schema, property) {
786
1269
  const value = def?.[property];
787
1270
  return isArray(value) ? value : undefined;
788
1271
  }
1272
+ /**
1273
+ * The payload of one entry of a schema's `checks` array.
1274
+ *
1275
+ * A CHECK IS ITSELF A ZOD INTERNAL, which is the whole reason this helper exists. Its own fields
1276
+ * live at `check._zod.def`, not on the check object, and they are keyed by a `check` discriminator
1277
+ * (`min_length`, `greater_than`, `string_format`, …) rather than by the Zod 3 `kind` property.
1278
+ *
1279
+ * Three readers in this file — {@link getStringFormat}, {@link getStringConstraints} and
1280
+ * {@link getNumberConstraints} — walked `checks` and read `check.kind`, which is `undefined` for
1281
+ * every check Zod 4 produces. None of them threw; each returned an empty answer, so a string with
1282
+ * `.max(120)` reported no maximum, a number with `.min(1)` reported no minimum, and `z.email()`
1283
+ * reported no format. Every consumer degraded silently: an `<input>` with no `maxLength`, no `min`,
1284
+ * and `type="text"` where `type="email"` was intended.
1285
+ */
1286
+ function getZodCheckDefinition(check) {
1287
+ if (typeof check !== 'object' || check === null)
1288
+ return undefined;
1289
+ const internals = check._zod;
1290
+ if (typeof internals === 'object' && internals !== null && 'def' in internals) {
1291
+ const definition = internals.def;
1292
+ if (typeof definition === 'object' && definition !== null)
1293
+ return definition;
1294
+ }
1295
+ // A check authored as a plain object (or a future Zod shape that flattens the payload) is read
1296
+ // directly rather than skipped — the discriminator lookup below tolerates a miss.
1297
+ return check;
1298
+ }
789
1299
  export function getZodDefStringProperty(schema, property) {
790
1300
  const def = getZodDef(schema);
791
1301
  const value = def?.[property];
@@ -955,34 +1465,82 @@ export function getShowWhenParams(schema) {
955
1465
  const metadata = getCustomFieldMetadata(schema);
956
1466
  return metadata[ZodDecorator.SHOW_WHEN];
957
1467
  }
1468
+ // =============================================================================
1469
+ // Enhanced Primitive Helpers
1470
+ // =============================================================================
1471
+ // ------------------------------------------------------------
1472
+ // STRING SCHEMA UTILITIES
1473
+ // ------------------------------------------------------------
1474
+ /**
1475
+ * Keep the TIGHTER of two bounds a schema declares, so several constraints compose the way the
1476
+ * validator composes them: every lower bound must be cleared, so the highest governs, and every
1477
+ * upper bound must be respected, so the lowest does.
1478
+ *
1479
+ * `undefined` means "nothing declared yet" and always loses, which is what makes these safe to fold
1480
+ * over a checks array in any order.
1481
+ */
1482
+ function tightestLowerBound(current, candidate) {
1483
+ return current === undefined ? candidate : Math.max(current, candidate);
1484
+ }
1485
+ function tightestUpperBound(current, candidate) {
1486
+ return current === undefined ? candidate : Math.min(current, candidate);
1487
+ }
958
1488
  /**
959
- * Get string format from schema checks (email, url, etc.)
1489
+ * The format a string schema declares — `email`, `url`, `datetime`, … — or `null` for a plain one.
1490
+ *
1491
+ * Zod 4 expresses a format TWO ways and both are read here, because an author picks between them
1492
+ * freely and the rendered field must not depend on which they chose:
1493
+ *
1494
+ * - a dedicated schema type (`z.email()`, `z.iso.datetime()`) carries `def.format` and no checks;
1495
+ * - a chained refinement (`z.string().email()`) carries a `string_format` CHECK whose own
1496
+ * definition names the format.
1497
+ *
1498
+ * `ipv4` and `ipv6` both answer `'ip'`: this vocabulary exists to choose an input type and a
1499
+ * display treatment, and those are the same for either family.
960
1500
  */
961
1501
  export function getStringFormat(schema) {
962
1502
  const unwrapped = unwrapSchema(schema);
963
1503
  if (getZodType(unwrapped) !== 'string') {
964
1504
  return null;
965
1505
  }
966
- const checks = getZodDefArrayProperty(unwrapped, 'checks') ?? [];
967
- for (const check of checks) {
968
- if (typeof check !== 'object' || check === null)
1506
+ const declared = getZodDefStringProperty(unwrapped, 'format');
1507
+ const fromDeclaration = normalizeStringFormat(declared);
1508
+ if (fromDeclaration)
1509
+ return fromDeclaration;
1510
+ for (const check of getZodDefArrayProperty(unwrapped, 'checks') ?? []) {
1511
+ const definition = getZodCheckDefinition(check);
1512
+ if (definition?.check !== 'string_format')
969
1513
  continue;
970
- const kind = check.kind;
971
- switch (kind) {
972
- case 'email': return 'email';
973
- case 'url': return 'url';
974
- case 'uuid': return 'uuid';
975
- case 'cuid': return 'cuid';
976
- case 'cuid2': return 'cuid2';
977
- case 'ulid': return 'ulid';
978
- case 'regex': return 'regex';
979
- case 'datetime': return 'datetime';
980
- case 'time': return 'time';
981
- case 'ip': return 'ip';
982
- }
1514
+ const fromCheck = normalizeStringFormat(definition.format);
1515
+ if (fromCheck)
1516
+ return fromCheck;
983
1517
  }
984
1518
  return null;
985
1519
  }
1520
+ /**
1521
+ * Zod's format name → this vocabulary. Unknown names answer `null` rather than being passed
1522
+ * through, because every consumer switches on {@link StringFormat} and an unrecognised value would
1523
+ * fall to a default anyway — but as a widened type, which is how a format nobody handles ends up
1524
+ * silently typed as one that is handled.
1525
+ */
1526
+ function normalizeStringFormat(value) {
1527
+ switch (value) {
1528
+ case 'email': return 'email';
1529
+ case 'url': return 'url';
1530
+ case 'uuid': return 'uuid';
1531
+ case 'cuid': return 'cuid';
1532
+ case 'cuid2': return 'cuid2';
1533
+ case 'ulid': return 'ulid';
1534
+ case 'regex': return 'regex';
1535
+ case 'datetime': return 'datetime';
1536
+ case 'time': return 'time';
1537
+ // Both IP families answer the same thing — see the note on `getStringFormat`.
1538
+ case 'ip':
1539
+ case 'ipv4':
1540
+ case 'ipv6': return 'ip';
1541
+ default: return null;
1542
+ }
1543
+ }
986
1544
  /**
987
1545
  * Get min/max length constraints from string schema
988
1546
  */
@@ -991,24 +1549,32 @@ export function getStringConstraints(schema) {
991
1549
  if (getZodType(unwrapped) !== 'string') {
992
1550
  return {};
993
1551
  }
994
- const checks = getZodDefArrayProperty(unwrapped, 'checks') ?? [];
995
1552
  const result = {};
996
- for (const check of checks) {
997
- if (typeof check !== 'object' || check === null)
998
- continue;
999
- const kind = check.kind;
1000
- const value = check.value;
1001
- if (typeof value !== 'number')
1553
+ // Zod 4 keys these by a `check` discriminator and names the bound after the constraint
1554
+ // (`minimum` / `maximum` / `length`), where Zod 3 used `kind` plus a shared `value`.
1555
+ //
1556
+ // Bounds ACCUMULATE toward the tightest rather than being assigned, because a schema may carry
1557
+ // several and the last one authored is not the one that governs. `z.string().min(3).nonempty()`
1558
+ // is ordinary authoring — a business floor plus a sanity floor — and assigning would report
1559
+ // `minLength: 1` for a schema that rejects `"ab"`. The same reasoning the truncation and the
1560
+ // exclusive-bound branches already follow: a hint looser than the schema invites a value the
1561
+ // server refuses, which is worse than no hint at all.
1562
+ for (const check of getZodDefArrayProperty(unwrapped, 'checks') ?? []) {
1563
+ const definition = getZodCheckDefinition(check);
1564
+ if (!definition)
1002
1565
  continue;
1003
- if (kind === 'min') {
1004
- result.minLength = value;
1566
+ const minimum = definition.minimum;
1567
+ const maximum = definition.maximum;
1568
+ const exactLength = definition.length;
1569
+ if (definition.check === 'min_length' && typeof minimum === 'number') {
1570
+ result.minLength = tightestLowerBound(result.minLength, minimum);
1005
1571
  }
1006
- if (kind === 'max') {
1007
- result.maxLength = value;
1572
+ if (definition.check === 'max_length' && typeof maximum === 'number') {
1573
+ result.maxLength = tightestUpperBound(result.maxLength, maximum);
1008
1574
  }
1009
- if (kind === 'length') {
1010
- result.minLength = value;
1011
- result.maxLength = value;
1575
+ if (definition.check === 'length_equals' && typeof exactLength === 'number') {
1576
+ result.minLength = tightestLowerBound(result.minLength, exactLength);
1577
+ result.maxLength = tightestUpperBound(result.maxLength, exactLength);
1012
1578
  }
1013
1579
  }
1014
1580
  return result;
@@ -1025,29 +1591,66 @@ export function getNumberConstraints(schema) {
1025
1591
  if (type !== 'number' && type !== 'int') {
1026
1592
  return {};
1027
1593
  }
1028
- const checks = getZodDefArrayProperty(unwrapped, 'checks') ?? [];
1029
1594
  const result = {};
1030
- // int type in Zod v4
1031
1595
  if (type === 'int') {
1032
1596
  result.isInt = true;
1033
1597
  result.step = 1;
1034
1598
  }
1599
+ // Zod 4 names a bound by DIRECTION rather than by role — `greater_than` / `less_than`, each
1600
+ // carrying an `inclusive` flag — where Zod 3 had `min` / `max`. Integer-ness is a `number_format`
1601
+ // check (`z.number().int()`) or the schema type itself (`z.int()`), never a `kind: 'int'`.
1602
+ //
1603
+ // Read INTEGER-NESS FIRST, in its own pass, because an exclusive bound cannot be translated
1604
+ // without knowing it — see below.
1605
+ //
1606
+ // It arrives the same TWO ways a string format does, and both must be read: `z.number().int()`
1607
+ // adds a `number_format` CHECK, while `z.int()` is its own schema type carrying `def.format` and
1608
+ // no checks at all. Reading only the check left `z.int()` — the shorter and more idiomatic
1609
+ // spelling — reporting no integer constraint and therefore no step.
1610
+ const declaredNumberFormat = getZodDefStringProperty(unwrapped, 'format');
1611
+ if (typeof declaredNumberFormat === 'string' && declaredNumberFormat.includes('int')) {
1612
+ result.isInt = true;
1613
+ result.step = 1;
1614
+ }
1615
+ const checks = getZodDefArrayProperty(unwrapped, 'checks') ?? [];
1035
1616
  for (const check of checks) {
1036
- if (typeof check !== 'object' || check === null)
1037
- continue;
1038
- const kind = check.kind;
1039
- const value = check.value;
1040
- if (kind === 'min' && typeof value === 'number') {
1041
- result.min = value;
1042
- }
1043
- if (kind === 'max' && typeof value === 'number') {
1044
- result.max = value;
1045
- }
1046
- if (kind === 'int') {
1617
+ const definition = getZodCheckDefinition(check);
1618
+ if (definition?.check === 'number_format' && typeof definition.format === 'string' && definition.format.includes('int')) {
1047
1619
  result.isInt = true;
1048
1620
  result.step = 1;
1049
1621
  }
1050
- if (kind === 'multipleOf' && typeof value === 'number') {
1622
+ }
1623
+ for (const check of checks) {
1624
+ const definition = getZodCheckDefinition(check);
1625
+ if (!definition)
1626
+ continue;
1627
+ const value = definition.value;
1628
+ if (typeof value !== 'number')
1629
+ continue;
1630
+ const inclusive = definition.inclusive === true;
1631
+ if (definition.check === 'greater_than') {
1632
+ // An EXCLUSIVE bound has no HTML equivalent: `min` is inclusive, so writing the raw value
1633
+ // would ADMIT a number the schema rejects — `z.number().positive()` becoming `min="0"` and
1634
+ // accepting zero. On an integer the next representable value is exact, so it translates; on a
1635
+ // float there is no honest answer and the constraint is left unstated rather than stated
1636
+ // wrongly. A field with no hint still validates; a field with a wrong hint invites a value
1637
+ // the server refuses.
1638
+ //
1639
+ // And for the same reason the result ACCUMULATES toward the tightest bound instead of being
1640
+ // assigned: `z.number().int().min(5).positive()` carries two lower bounds, and taking the
1641
+ // last would report `min: 1` for a schema that rejects 1 through 4.
1642
+ if (inclusive)
1643
+ result.min = tightestLowerBound(result.min, value);
1644
+ else if (result.isInt)
1645
+ result.min = tightestLowerBound(result.min, value + 1);
1646
+ }
1647
+ if (definition.check === 'less_than') {
1648
+ if (inclusive)
1649
+ result.max = tightestUpperBound(result.max, value);
1650
+ else if (result.isInt)
1651
+ result.max = tightestUpperBound(result.max, value - 1);
1652
+ }
1653
+ if (definition.check === 'multiple_of') {
1051
1654
  result.step = value;
1052
1655
  }
1053
1656
  }
@@ -1065,17 +1668,14 @@ export function getNumberConstraints(schema) {
1065
1668
  export function getEnumValues(schema) {
1066
1669
  const unwrapped = unwrapSchema(schema);
1067
1670
  const type = getZodType(unwrapped);
1068
- // Handle string literal types (z.literal("value")) - return single value as array
1069
- // Only string literals are treated as enums (for select fields)
1070
- // In Zod v4, literal values are stored in def.values as an array
1671
+ // A string literal is a vocabulary — usually of one value (`z.literal('value')`), and in Zod 4
1672
+ // legitimately of several (`z.literal(['a', 'b'])`), which stores every value in `def.values`.
1673
+ // EVERY string value is the vocabulary: reading only `values[0]` planned and worded one value of a
1674
+ // multi-value discriminator and rendered every other member's row raw (#1096). Non-string literals
1675
+ // (numbers, booleans, null) are not select-field vocabularies and contribute nothing.
1071
1676
  if (type === 'literal') {
1072
- const values = getZodDefProperty(unwrapped, 'values');
1073
- const literalValue = values?.[0];
1074
- // Only string literals are valid enum values
1075
- if (typeof literalValue === 'string') {
1076
- return [literalValue];
1077
- }
1078
- return [];
1677
+ const values = getZodDefProperty(unwrapped, 'values') ?? [];
1678
+ return values.filter((value) => typeof value === 'string');
1079
1679
  }
1080
1680
  // Handle union types - combine enum values from all options
1081
1681
  // This handles cases like z.union([z.enum(A), z.enum(B)])
@@ -1386,7 +1986,23 @@ export function getArrayItemSchema(schema) {
1386
1986
  return element ?? null;
1387
1987
  }
1388
1988
  /**
1389
- * Get min/max constraints for array
1989
+ * How many items an array schema admits — `z.array(x).min(1).max(3)`.
1990
+ *
1991
+ * READS ZOD 4, and did not until 2026-09-20: it looked for `def.minLength` / `def.maxLength`
1992
+ * carrying a `{ value }` object, which is the ZOD 3 shape. Zod 4 puts array bounds in `def.checks`
1993
+ * under a `check` discriminator (`min_length` / `max_length` / `length_equals`) with the bound
1994
+ * named after the constraint, exactly as the string reader above already handles. So this returned
1995
+ * `{}` for every array ever declared — measured, all three of `.min(1).max(2)`, `.max(1)` and no
1996
+ * bound at all answered the same thing.
1997
+ *
1998
+ * The cost was silent and total: an author could write `.max(3)` on a multi-select and get no
1999
+ * limit, no disabled options and no hint, with the only enforcement left at submit — and the one
2000
+ * control that read this looked, in its source, exactly like a control that honoured it.
2001
+ *
2002
+ * Bounds ACCUMULATE toward the tightest, for the same reason the string reader states: a schema
2003
+ * may carry several (`.min(1).nonempty()` is ordinary authoring) and the last one authored is not
2004
+ * the one that governs. A hint looser than the schema invites a value the server refuses, which is
2005
+ * worse than no hint at all.
1390
2006
  */
1391
2007
  export function getArrayConstraints(schema) {
1392
2008
  const unwrapped = unwrapSchema(schema);
@@ -1394,17 +2010,23 @@ export function getArrayConstraints(schema) {
1394
2010
  return {};
1395
2011
  }
1396
2012
  const result = {};
1397
- const minLength = getZodDefProperty(unwrapped, 'minLength');
1398
- const maxLength = getZodDefProperty(unwrapped, 'maxLength');
1399
- if (minLength && typeof minLength === 'object' && 'value' in minLength) {
1400
- const value = minLength.value;
1401
- if (typeof value === 'number')
1402
- result.minLength = value;
1403
- }
1404
- if (maxLength && typeof maxLength === 'object' && 'value' in maxLength) {
1405
- const value = maxLength.value;
1406
- if (typeof value === 'number')
1407
- result.maxLength = value;
2013
+ for (const check of getZodDefArrayProperty(unwrapped, 'checks') ?? []) {
2014
+ const definition = getZodCheckDefinition(check);
2015
+ if (!definition)
2016
+ continue;
2017
+ const minimum = definition.minimum;
2018
+ const maximum = definition.maximum;
2019
+ const exactLength = definition.length;
2020
+ if (definition.check === 'min_length' && typeof minimum === 'number') {
2021
+ result.minLength = tightestLowerBound(result.minLength, minimum);
2022
+ }
2023
+ if (definition.check === 'max_length' && typeof maximum === 'number') {
2024
+ result.maxLength = tightestUpperBound(result.maxLength, maximum);
2025
+ }
2026
+ if (definition.check === 'length_equals' && typeof exactLength === 'number') {
2027
+ result.minLength = tightestLowerBound(result.minLength, exactLength);
2028
+ result.maxLength = tightestUpperBound(result.maxLength, exactLength);
2029
+ }
1408
2030
  }
1409
2031
  return result;
1410
2032
  }
@@ -1412,7 +2034,21 @@ export function getArrayConstraints(schema) {
1412
2034
  // RECORD/MAP SCHEMA UTILITIES
1413
2035
  // ------------------------------------------------------------
1414
2036
  /**
1415
- * Get the value schema from a record schema
2037
+ * Get the value schema from a record schema.
2038
+ *
2039
+ * A record's keys are DATA rather than declared fields, so it has no `shape` and
2040
+ * {@link getObjectShape} correctly answers `null` for one. What a consumer still needs is the
2041
+ * single schema every entry's value shares — enough to render each entry through the ordinary
2042
+ * field dispatch instead of as raw text.
2043
+ *
2044
+ * That is not hypothetical: `FieldSchemaType.RECORD` had no display branch at all until
2045
+ * 2026-09-18, so a record field fell through `DisplayValueDispatcher` to its "unsupported type"
2046
+ * chip and rendered an ERROR over data that was fully present — measured on four settings panes
2047
+ * (an organization's `Limits` and `Limit overrides`, and SSO's `Domain verification status` on
2048
+ * both the organization and application surfaces). This function existed the whole time; the
2049
+ * display simply never called it.
2050
+ *
2051
+ * Unwraps first, so an `.optional()` / `.default()` / `.nullable()` record answers like a bare one.
1416
2052
  */
1417
2053
  export function getRecordValueSchema(schema) {
1418
2054
  const unwrapped = unwrapSchema(schema);
@@ -1422,6 +2058,59 @@ export function getRecordValueSchema(schema) {
1422
2058
  const valueSchema = getZodDefSchemaProperty(unwrapped, 'valueType');
1423
2059
  return valueSchema ?? null;
1424
2060
  }
2061
+ /**
2062
+ * The KEY schema of a RECORD, or `null` when the schema is not a record — {@link getRecordValueSchema}'s twin.
2063
+ *
2064
+ * A record's keys are data, but they can still belong to a named vocabulary: an organization's `limits` is keyed by
2065
+ * limit identifiers, declared `z.record(z.string().systemEnum('Limit'), …)`, and a display reads each key's word
2066
+ * through the vocabulary the key schema names (#1851) instead of printing the identifier.
2067
+ *
2068
+ * Unwraps first, like {@link getRecordValueSchema}.
2069
+ */
2070
+ export function getRecordKeySchema(schema) {
2071
+ const unwrapped = unwrapSchema(schema);
2072
+ if (getZodType(unwrapped) !== 'record') {
2073
+ return null;
2074
+ }
2075
+ const keySchema = getZodDefSchemaProperty(unwrapped, 'keyType');
2076
+ return keySchema ?? null;
2077
+ }
2078
+ /**
2079
+ * The element schema of a SET, or `null` when the schema is not a set.
2080
+ *
2081
+ * Sibling of {@link getRecordValueSchema} and {@link getMapSchemas}, and added for the same reason
2082
+ * that one records: `FieldSchemaType.SET` classified correctly and had no display branch, so a set
2083
+ * field fell through `DisplayValueDispatcher` to its "unsupported type" chip and rendered an ERROR
2084
+ * over data that was fully present. A set is an ARRAY without order or duplicates, so its element
2085
+ * schema is the only thing a renderer needs to treat it as one.
2086
+ *
2087
+ * Unwraps first, so an `.optional()` / `.default()` / `.nullable()` set answers like a bare one.
2088
+ */
2089
+ export function getSetValueSchema(schema) {
2090
+ const unwrapped = unwrapSchema(schema);
2091
+ if (getZodType(unwrapped) !== 'set') {
2092
+ return null;
2093
+ }
2094
+ return getZodDefSchemaProperty(unwrapped, 'valueType') ?? null;
2095
+ }
2096
+ /**
2097
+ * The positional item schemas of a TUPLE, or `null` when the schema is not a tuple.
2098
+ *
2099
+ * A tuple differs from an array in exactly one way that matters to a renderer: each POSITION has
2100
+ * its own schema, so the items cannot share one element type. Returning the list lets a display
2101
+ * branch pair `value[i]` with `items[i]` and render each position as the type it actually is,
2102
+ * rather than formatting the whole tuple as text.
2103
+ *
2104
+ * Unwraps first, like its siblings.
2105
+ */
2106
+ export function getTupleItemSchemas(schema) {
2107
+ const unwrapped = unwrapSchema(schema);
2108
+ if (getZodType(unwrapped) !== 'tuple') {
2109
+ return null;
2110
+ }
2111
+ const items = getZodDefProperty(unwrapped, 'items');
2112
+ return Array.isArray(items) ? items : null;
2113
+ }
1425
2114
  /**
1426
2115
  * Get the key and value schemas from a map schema
1427
2116
  */
@@ -1869,6 +2558,45 @@ export function getUniqueFieldParams(schema) {
1869
2558
  return { scope: 'global', sparse: true }; // Default params from original getUniqueParams
1870
2559
  return value;
1871
2560
  }
2561
+ /**
2562
+ * The NAME of the field an object schema marks as its primary key, or `undefined`.
2563
+ *
2564
+ * ## Why this is not `resourceFieldIdentifier`
2565
+ *
2566
+ * A resource has two different id-shaped names and they are not interchangeable:
2567
+ *
2568
+ * - `resourceFieldIdentifier` (`organizationSsoConfigId`, `todoId`) is the URL PARAMETER name. It
2569
+ * appears in routes and in contextual parameters. A response payload does not generally carry it.
2570
+ * - the PRIMARY KEY field (`_id`) is what the row calls its own identity, and is what a payload
2571
+ * actually contains.
2572
+ *
2573
+ * Conflating them is a real defect rather than a hypothetical one. `ResourceAutomaticDisplayer`'s
2574
+ * READ-bootstrap fallback — the path that exists so a row without an id in its URL can still be
2575
+ * identified once its data arrives — read `data[resourceFieldIdentifier]`. For an ordinary resource
2576
+ * the fallback never runs (the id is in the URL, so the earlier branch returns), and for a
2577
+ * CONTEXT-ADDRESSED SINGLETON, which is the only shape that needs it, the key is absent from the
2578
+ * payload and the lookup yields `undefined`.
2579
+ *
2580
+ * Measured 2026-09-19 on the running application: `organizationSsoConfig` loads
2581
+ * `["_id", "enforcement", "domains", …]` while the fallback asked for `organizationSsoConfigId`. So
2582
+ * the resource id stayed undefined after a successful READ, the mutation manager could not attach
2583
+ * (it is keyed on that id), and `canInlineEdit` was false — which is why every settings singleton in
2584
+ * the product was silently read-only, and why declaring `inlineEditable` on one would have changed
2585
+ * nothing.
2586
+ *
2587
+ * Returns the FIRST field marked `.isPrimaryKey()`. A schema declaring several is malformed; a
2588
+ * schema declaring none — a DTO projection, a nested object — yields `undefined`, and every caller
2589
+ * treats that as "no id", which is the safe reading.
2590
+ */
2591
+ export function getPrimaryKeyFieldName(schema) {
2592
+ const shape = getZodObjectShape(schema);
2593
+ for (const [fieldName, fieldSchema] of Object.entries(shape)) {
2594
+ if (isPrimaryKeyField(fieldSchema)) {
2595
+ return fieldName;
2596
+ }
2597
+ }
2598
+ return undefined;
2599
+ }
1872
2600
  /**
1873
2601
  * Check if a schema field has the isPrimaryKey decorator (recursive traversal through wrappers).
1874
2602
  */
@@ -2140,6 +2868,30 @@ export function getDurationConstraints(schema) {
2140
2868
  allowedUnits: metadata.allowedUnits,
2141
2869
  };
2142
2870
  }
2871
+ /**
2872
+ * Check if a schema is an address schema (created by z_address()).
2873
+ * Detects by the presence of addressConfig in SCHEMA_METADATA, on the schema or its unwrapped inner schema.
2874
+ */
2875
+ export function isAddressSchema(schema) {
2876
+ return getAddressMetadata(schema) !== undefined;
2877
+ }
2878
+ /**
2879
+ * Get the address metadata from a schema, or undefined when it is not an address schema.
2880
+ */
2881
+ export function getAddressMetadata(schema) {
2882
+ const schemaMetadata = schema[SCHEMA_METADATA];
2883
+ if (schemaMetadata?.addressConfig?.isAddress === true) {
2884
+ return schemaMetadata.addressConfig;
2885
+ }
2886
+ const unwrapped = unwrapSchema(schema);
2887
+ if (unwrapped !== schema) {
2888
+ const unwrappedMetadata = unwrapped[SCHEMA_METADATA];
2889
+ if (unwrappedMetadata?.addressConfig?.isAddress === true) {
2890
+ return unwrappedMetadata.addressConfig;
2891
+ }
2892
+ }
2893
+ return undefined;
2894
+ }
2143
2895
  /**
2144
2896
  * Check if a schema is a phone schema (created by z_phone()).
2145
2897
  * Detects by the presence of phoneConfig in SCHEMA_METADATA.
@@ -2489,6 +3241,18 @@ const ENUM_EDIT_MODE_BY_FORM_COMPONENT = {
2489
3241
  [FormFieldComponent.SELECT]: EnumEditMode.SELECT,
2490
3242
  [FormFieldComponent.RADIO_GROUP]: EnumEditMode.RADIO,
2491
3243
  };
3244
+ /**
3245
+ * The MULTIPLE-selection twin of the map above, for the other `edit` axis an author can use.
3246
+ *
3247
+ * `CHECKBOX_GROUP` is deliberately absent from the single-select map — the test
3248
+ * `does NOT map CHECKBOX_GROUP onto an enum mode` pins that, because a checkbox group is an
3249
+ * array-of-enum control and has no single-select meaning. It belongs HERE, where it does, and it
3250
+ * is the only member: `FormFieldComponent` has no `COMBOBOX`, which is why the searchable multi
3251
+ * had to borrow `EnumEditMode.COMBOBOX` before this vocabulary existed.
3252
+ */
3253
+ const ENUM_MULTIPLE_EDIT_MODE_BY_FORM_COMPONENT = {
3254
+ [FormFieldComponent.CHECKBOX_GROUP]: EnumMultipleEditMode.CHECKBOX,
3255
+ };
2492
3256
  const NUMBER_EDIT_MODE_BY_FORM_COMPONENT = {
2493
3257
  [FormFieldComponent.INPUT]: NumberEditMode.INPUT,
2494
3258
  [FormFieldComponent.SLIDER]: NumberEditMode.SLIDER,
@@ -2517,6 +3281,21 @@ export function resolveEnumEditMode(schema) {
2517
3281
  const component = getFormFieldComponent(schema);
2518
3282
  return (component !== undefined ? ENUM_EDIT_MODE_BY_FORM_COMPONENT[component] : undefined) ?? EnumEditMode.SELECT;
2519
3283
  }
3284
+ /**
3285
+ * Resolve the shape an ARRAY OF ENUM VALUES edits as.
3286
+ *
3287
+ * Takes the ELEMENT schema, which is where `enumUI()` lives. The default is CHECKBOX and not the
3288
+ * single vocabulary's SELECT: a checkbox group is the one multiple control that says "any number
3289
+ * of these" without the reader having to open it first, and it is what an undeclared enum array
3290
+ * has always rendered as.
3291
+ */
3292
+ export function resolveEnumMultipleEditMode(itemSchema) {
3293
+ const declared = getEnumUI(itemSchema)?.edit?.multipleEditMode;
3294
+ if (declared !== undefined)
3295
+ return declared;
3296
+ const component = getFormFieldComponent(itemSchema);
3297
+ return (component !== undefined ? ENUM_MULTIPLE_EDIT_MODE_BY_FORM_COMPONENT[component] : undefined) ?? EnumMultipleEditMode.CHECKBOX;
3298
+ }
2520
3299
  /** Resolve the shape a number field edits as, honoring both edit axes. */
2521
3300
  export function resolveNumberEditMode(schema) {
2522
3301
  const declared = getNumberUI(schema)?.edit?.editMode;
@@ -2641,11 +3420,140 @@ export function isNotEditableForOperation(schema, operation) {
2641
3420
  return false;
2642
3421
  }
2643
3422
  /**
2644
- * Check if a schema has inline editable configuration.
2645
- * Reads from `{primitive}UI.display.inlineEditable`.
3423
+ * The readable stand-in for an unresolvable foreign key, taken from the record that HOLDS it.
3424
+ *
3425
+ * Returns the value of the sibling named by `foreignKeyUI.display.labelFallbackField`, or
3426
+ * `undefined` when the field declares none, the record is absent, or the sibling is empty. Every
3427
+ * caller treats `undefined` as "fall through to the id", which is the behaviour before this existed
3428
+ * — so a field that declares nothing renders exactly as it did.
3429
+ *
3430
+ * Non-string siblings are refused rather than coerced: a label is text, and `String(someObject)`
3431
+ * would put `[object Object]` on screen, which is worse than the id it replaced.
3432
+ */
3433
+ export function resolveForeignKeyLabelFallback(schema, record) {
3434
+ if (!schema || !record)
3435
+ return undefined;
3436
+ const fallbackField = getForeignKeyUIDisplay(schema)?.labelFallbackField;
3437
+ if (!fallbackField)
3438
+ return undefined;
3439
+ const raw = record[fallbackField];
3440
+ return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
3441
+ }
3442
+ /**
3443
+ * WHY this field has nothing to show — see {@link DisplayEmptyReason}.
3444
+ *
3445
+ * `isCollectionEmptiness` is supplied by the CALL SITE, because only the renderer knows which kind
3446
+ * of emptiness it met: the dispatcher reaches its empty branches separately for a null scalar, a
3447
+ * zero-length array, a keyless object and an entryless record. Re-deriving that from the value here
3448
+ * would be a second, weaker answer to a question the caller has already answered exactly — and it
3449
+ * would get `[]` on a field whose schema says OBJECT wrong.
3450
+ *
3451
+ * The remaining split is `.excludeFromUpdate()`: a field the UPDATE contract strips is one the
3452
+ * operator cannot fill, so its emptiness is the system's to resolve, not theirs. That predicate is
3453
+ * the same one {@link isEligibleForLayoutDefaultInlineEdit} uses to decide whether to offer a
3454
+ * control, which is what keeps the two answers consistent: a field that gets no control also does
3455
+ * not get a word inviting input.
3456
+ */
3457
+ export function resolveDisplayEmptyReason(schema, isCollectionEmptiness) {
3458
+ if (isCollectionEmptiness)
3459
+ return DisplayEmptyReason.NONE;
3460
+ if (schema && isExcludedFromUpdate(schema))
3461
+ return DisplayEmptyReason.NOT_PROVIDED;
3462
+ return DisplayEmptyReason.NOT_SET;
3463
+ }
3464
+ /**
3465
+ * The component label key that words a given empty reason.
3466
+ *
3467
+ * One named mapping rather than a ternary at each of the dispatcher's four empty branches — the
3468
+ * shape `.claude/rules/invariant-call-site-enumeration.md` exists to refuse. It is TOTAL over the
3469
+ * vocabulary (`Record<DisplayEmptyReason, string>`), so a reason added tomorrow is a compile error
3470
+ * here rather than a silent fallthrough to the generic word.
3471
+ *
3472
+ * `noValue` is deliberately KEPT as the NOT_SET key rather than renamed. It is already declared,
3473
+ * already realised in every application's generated tree, and already the right words for that
3474
+ * case; renaming it would restale every label unit in every application to say the same thing.
3475
+ */
3476
+ export const DISPLAY_EMPTY_REASON_LABEL_KEYS = {
3477
+ [DisplayEmptyReason.NOT_SET]: 'noValue',
3478
+ [DisplayEmptyReason.NONE]: 'noValueNone',
3479
+ [DisplayEmptyReason.NOT_PROVIDED]: 'noValueNotProvided',
3480
+ };
3481
+ /**
3482
+ * Does this field DECLARE that it is inline-editable?
3483
+ *
3484
+ * Reads `{primitive}UI.display.inlineEditable`, which is `boolean | {…}`.
3485
+ *
3486
+ * ## `false` means FALSE — corrected 2026-09-19
3487
+ *
3488
+ * This used to be `inlineEditable != null`, so an authored `inlineEditable: false` answered TRUE
3489
+ * and the field was routed to `DisplayFieldEditable` exactly like an opted-IN one. There,
3490
+ * `getInlineEditableParams` returns `undefined` for `false` and the component falls back to
3491
+ * `DEFAULT_INLINE_EDIT_PARAMS` — so the explicit opt-out produced the DEFAULT inline editor. An
3492
+ * author writing `false` got the opposite of what they asked for, with nothing to grep for.
3493
+ *
3494
+ * Nothing in the repository authored `false` at the time of the fix (measured: zero occurrences
3495
+ * across `engine/`, `platform/` and both dogfood applications), so no behaviour changed on the day.
3496
+ * What changed is that the opt-out became REAL, which is what
3497
+ * {@link ResourceLayoutInlineEditDefault} needs: a layout-level default is only safe if a single
3498
+ * field can decline it.
2646
3499
  */
2647
3500
  export function hasInlineEditableDecorator(schema) {
2648
- return getFieldUIDisplayConfig(schema)?.inlineEditable != null;
3501
+ return getFieldUIDisplayConfig(schema)?.inlineEditable != null
3502
+ && getFieldUIDisplayConfig(schema)?.inlineEditable !== false;
3503
+ }
3504
+ /**
3505
+ * Has this field explicitly DECLINED inline editing?
3506
+ *
3507
+ * Distinct from `!hasInlineEditableDecorator(schema)`, and the distinction is the whole point of
3508
+ * the layout-level default: a field that says nothing is a candidate for the default, while a field
3509
+ * that says `false` has opted out and must stay read-only even under a layout that turns the
3510
+ * default on.
3511
+ */
3512
+ export function declinesInlineEdit(schema) {
3513
+ return getFieldUIDisplayConfig(schema)?.inlineEditable === false;
3514
+ }
3515
+ /**
3516
+ * Would a layout whose `defaultInlineEdit` is on make THIS field editable in place?
3517
+ *
3518
+ * The predicate is derived from what the field already declares about itself, rather than from a
3519
+ * hand-kept list of editable field names — so a field added to a settings schema tomorrow is
3520
+ * covered without anyone remembering to add it, and a field that is not writable is never offered a
3521
+ * control that would fail on save.
3522
+ *
3523
+ * A field qualifies when ALL of these hold:
3524
+ *
3525
+ * - it has not DECLINED (`inlineEditable: false`) — an explicit author decision outranks a default;
3526
+ * - it is not `.excludeFromUpdate()` — the UPDATE DTO strips it, so a control here would collect a
3527
+ * value the server silently discards. That is worse than no control, because the row appears to
3528
+ * save and does not;
3529
+ * - it is not `.isHidden()` for display — a field nobody may read is not a field anybody may write.
3530
+ *
3531
+ * ## The primary key needs no clause of its own — measured, not assumed
3532
+ *
3533
+ * An `isPrimaryKey(schema)` guard was written here first, on the reasoning that an identity is not a
3534
+ * setting. It is UNREACHABLE: `.isPrimaryKey()` already reports `isHiddenForDisplay() === true`, so
3535
+ * the hidden clause below decides every primary key before a dedicated clause could. Deleting it
3536
+ * left all ten pins green, which is how it was found.
3537
+ *
3538
+ * It is removed rather than kept "for safety". An unreachable guard is not defence in depth — it is
3539
+ * an untested claim that reads like coverage (`.claude/rules/invariant-call-site-enumeration.md`
3540
+ * #19, #25), and the next author would have reasonably assumed it was load-bearing. The N/A is
3541
+ * stated here instead. If a primary key ever stops being hidden for display, this is the paragraph
3542
+ * that has to be re-read.
3543
+ *
3544
+ * Note what is deliberately NOT tested: the field's TYPE. A boolean, a string, an enum and an array
3545
+ * are all legitimately editable in place, and the display dispatcher already routes each to the
3546
+ * right control (`DisplayFieldEditableArray` for arrays, the switch fast-path for booleans). Adding
3547
+ * a type filter here would silently re-narrow what the layout author asked for.
3548
+ */
3549
+ export function isEligibleForLayoutDefaultInlineEdit(schema) {
3550
+ if (declinesInlineEdit(schema))
3551
+ return false;
3552
+ if (isExcludedFromUpdate(schema))
3553
+ return false;
3554
+ if (isHiddenForDisplay(schema))
3555
+ return false;
3556
+ return true;
2649
3557
  }
2650
3558
  /**
2651
3559
  * Get the resolved inline editable params from `{primitive}UI.display.inlineEditable`.
@@ -2907,7 +3815,11 @@ export function serializeZodSchema(schema, seen = new Set()) {
2907
3815
  const literalValues = (getZodDefArrayProperty(inner, 'values') ?? [getZodDefProperty(inner, 'value')])
2908
3816
  .filter((entry) => entry !== undefined && entry !== null);
2909
3817
  const value = literalValues[0];
2910
- return withOptionals(withMeta({ type: 'literal', value }), optional, nullable);
3818
+ // A multi-value literal (`z.literal(['a', 'b'])`) carries EVERY value it admits (#1106): serialized
3819
+ // as `value: 'a'` alone it round-tripped as `z.literal('a')` and refused `b`. `value` stays the first
3820
+ // entry for the single-value case every reader already understands; `values` is present only when
3821
+ // there is more than one, and the deserializer prefers it.
3822
+ return withOptionals(withMeta({ type: 'literal', value, ...(literalValues.length > 1 ? { values: literalValues } : {}) }), optional, nullable);
2911
3823
  }
2912
3824
  case 'enum': {
2913
3825
  // In Zod 4, both z.enum() and z.enum() use type 'enum'
@@ -3068,7 +3980,9 @@ export function deserializeZodSchema(desc) {
3068
3980
  return applyOptionalNullable(s, d);
3069
3981
  }
3070
3982
  case 'literal': {
3071
- const s = applyMetadata(z.literal(d.value), d.metadata);
3983
+ // `values` is the whole admitted set of a multi-value literal (#1106); `value` alone is a single one.
3984
+ const literalValues = d.values;
3985
+ const s = applyMetadata(z.literal((literalValues && literalValues.length > 1 ? literalValues : d.value)), d.metadata);
3072
3986
  return applyOptionalNullable(s, d);
3073
3987
  }
3074
3988
  case 'enum': {