From 15f47af278161c17a83dde44373b4a2092a0f0fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:13:02 +0000 Subject: [PATCH 1/7] =?UTF-8?q?wip(spec):=20lookupAutofill=20=E2=80=94=20d?= =?UTF-8?q?eclared=20lookup-pick=20autofill=20on=20a=20relationship=20fiel?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyU9JHYh6atGVnEXr3 --- packages/spec/src/data/field.zod.ts | 75 +++++++++++++++ packages/spec/src/data/object.zod.ts | 88 ++++++++++++++++++ packages/spec/src/stack.zod.ts | 132 +++++++++++++++++++++++++++ 3 files changed, 295 insertions(+) diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index ba36c8e6613..08aea3f28d3 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1963,6 +1963,55 @@ export const FieldSchema = lazySchema(() => { })])).optional().describe("Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; {field,param} when the remote filter key differs — the {field,param} form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent."), allowCreate: z.boolean().optional().describe('Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field.'), + /** + * LOOKUP-PICK AUTOFILL — which fields of the record being edited a pick in + * this lookup fills, each from a named field of the picked record + * (`target ← lookup.source`): a quote line's `unit_price` from the picked + * product's `list_price`. + * + * ⭐ The FIELD is its one home, not a form view. A record form draws this + * field from its definition whichever form is showing it (a declared form + * view, a `formViews` entry, or the derived form an object without one + * gets), and the inline line-item grid hydrates its columns from the same + * child field definitions — so both read one declaration. A form-view key + * would be invisible to the grid and to every other form of the object. + * + * ⭐ The overwrite rule is part of the contract, stated in the describe: a + * pick never replaces a value somebody wrote unless the pair says + * `overwrite: true`. + * + * ⭐ Relation to the grid column key `autofill` (`InlineGridColumnSchema`), + * which is unchanged: that key governs only the grid's implicit SAME-NAMED + * copy (on by default, overwriting). A declared pair applies in the grid too, + * under this key's own overwrite rule, and takes its target out of the + * same-named copy, so one target never has two writers on one pick. + * + * Three doors judge it: the parse (this field's superRefine — a single-value + * relationship only), the object's parse (`refuseDishonestLookupAutofill` in + * `object.zod.ts` — each target a writable field of the same object, named + * once), and the stack's cross-reference pass (`collectLookupAutofillErrors` + * in `stack.zod.ts` — each source a readable field of the referenced object + * whose value the target can hold). + */ + lookupAutofill: z.array(strictObject({ + surface: 'this lookupAutofill entry', + history: 'A new shape, closed from its first release: a misspelt key in a pair would otherwise fill nothing and say nothing.', + aliases: { + field: 'target', to: 'target', targetField: 'target', + from: 'source', sourceField: 'source', lookupField: 'source', + replace: 'overwrite', force: 'overwrite', + }, + }, { + target: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Field of THIS object the pick fills (snake_case machine name).'), + source: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe("Field of the picked record — a field of this lookup's `reference` object — whose value is copied (snake_case machine name)."), + overwrite: z.boolean().optional().describe('TRUE: the pick replaces whatever the target holds, a typed value included. Absent or FALSE: the pick fills the target only while it is empty or still holds the value an earlier pick filled in.'), + })).min(1).optional().describe( + "Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. " + + "OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. " + + 'Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. ' + + "Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy.", + ), + /** * Calculation — CEL formula. Plain string accepted for back-compat; build emits canonical envelope. * On a `formula` field it is computed on read and has no column. On any other (stored) field the @@ -2493,6 +2542,32 @@ export const FieldSchema = lazySchema(() => { "`type: 'email'` if it holds an email address, or drop the key.", }); } + // [#27945] `lookupAutofill` copies fields of the ONE record a pick chooses. + // Off a relationship there is no pick, and on a multi-value relationship a + // pick chooses several records with no single source to copy from — either + // way the declaration would parse and fill nothing. The target and source + // halves need the object's field map and the stack's objects, and are judged + // in `ObjectSchema`'s superRefine and the stack's cross-reference pass. + if (field.lookupAutofill !== undefined && field.type !== 'lookup' && field.type !== 'master_detail') { + ctx.addIssue({ + code: 'custom', + path: ['lookupAutofill'], + message: + "`lookupAutofill` is only valid on `type: 'lookup'` or `type: 'master_detail'` (this field is " + + `\`${field.type}\`): it fills fields of the record being edited from the record a pick in this ` + + 'field chooses, and only a relationship field has a pick. Declare it on the relationship field ' + + 'whose pick should fill the form, or drop the key.', + }); + } else if (field.lookupAutofill !== undefined && field.multiple === true) { + ctx.addIssue({ + code: 'custom', + path: ['lookupAutofill'], + message: + '`lookupAutofill` is not valid on a multi-value relationship (`multiple: true`): a pick there ' + + 'chooses several records, so there is no single record to copy each `source` from. Drop the key, ' + + 'or make the field single-value if one record is picked.', + }); + } // [#13632] (ADR-0049 declared = enforced): the `reference` TSDoc above has // always called the key REQUIRED on the relationship types, but the schema diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 3e50383e5b5..944f7a37308 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2048,6 +2048,91 @@ function refuseDishonestReferenceDeletedWhen( } } +/** Field types whose stored value the engine computes, so a filled value never stands. */ +const LOOKUP_AUTOFILL_COMPUTED_TYPES: ReadonlySet = new Set(['formula', 'summary', 'autonumber']); + +/** + * [#27945] Each `lookupAutofill` pair's `target` names a field of THIS object + * that an author writes: a pick fills it on the form, and the form saves it as + * an ordinary write. A target the write path drops or recomputes would show a + * value the save then loses, and a target named twice would have two writers + * on one pick — each parses and fills nothing that lasts (ADR-0049 declared = + * enforced). + * + * Refused, one located issue per offending pair at + * `['fields', , 'lookupAutofill', , 'target']`, when the target: + * + * - is the declaring lookup itself (a pick already writes it); + * - names no declared field of this object; + * - is `readonly: true` (a non-system write drops it from the payload); + * - is computed — `formula`, `summary`, `autonumber`, or a stored field with + * an `expression` (the engine writes it, so a client value never stands); + * - is named by an earlier pair of the same field. + * + * Judged here, in the object's `superRefine` beside + * {@link refuseDishonestReferenceDeletedWhen}: `FieldSchema` sees one field at + * a time and refuses only the half it can see (the key off a single-value + * relationship). The SOURCE half needs the referenced object, which only the + * stack holds — `collectLookupAutofillErrors` in `stack.zod.ts`. Judged at + * parse, so every door — `defineStack`, a raw `.parse()`, the save door — + * refuses it; the key is new, so no metadata at rest carries it. + */ +function refuseDishonestLookupAutofill( + ownName: unknown, + fields: unknown, + ctx: z.RefinementCtx, +): void { + if (fields === null || typeof fields !== 'object') return; + const objectName = typeof ownName === 'string' ? ownName : ''; + const fieldMap = fields as Record; + for (const [fieldName, fieldDef] of Object.entries(fieldMap)) { + const pairs = (fieldDef as { lookupAutofill?: unknown } | null)?.lookupAutofill; + if (!Array.isArray(pairs)) continue; + const seen = new Set(); + pairs.forEach((pair: unknown, i: number) => { + const target = (pair as { target?: unknown } | null)?.target; + if (typeof target !== 'string') return; + const path = ['fields', fieldName, 'lookupAutofill', i, 'target']; + const where = `Field \`${fieldName}\` on object \`${objectName}\` declares \`lookupAutofill[${i}].target: '${target}'\``; + const prescription = + '`lookupAutofill[].target` must name a field of this object, other than the lookup itself, that an ' + + 'author writes — not `readonly`, not computed — and each target once.'; + const refuse = (reason: string): void => { + ctx.addIssue({ code: 'custom', path, message: `${where}, ${reason} ${prescription}` }); + }; + if (target === fieldName) { + refuse('which is the lookup itself: a pick already writes the picked id there.'); + return; + } + if (!Object.prototype.hasOwnProperty.call(fieldMap, target)) { + refuse(`but the object declares no field \`${target}\`.`); + return; + } + const targetDef = fieldMap[target] as + | { type?: unknown; readonly?: unknown; expression?: unknown } + | null + | undefined; + if (typeof targetDef?.type === 'string' && LOOKUP_AUTOFILL_COMPUTED_TYPES.has(targetDef.type)) { + refuse(`a \`${targetDef.type}\` field: the engine computes its value, so a filled value never stands.`); + return; + } + if (targetDef?.expression !== undefined) { + refuse('a field computed from its `expression` on every write, so a filled value never stands.'); + return; + } + if (targetDef?.readonly === true) { + refuse('a `readonly` field: a non-system write drops it from the payload, so the filled value is never saved.'); + return; + } + if (seen.has(target)) { + refuse('a target an earlier pair of this field already fills: one pick would write it twice.'); + return; + } + seen.add(target); + }); + } +} + /** * [#23809, maintainer ruling on the card: 「同意」] The field an object's * `stageField` names declares a `kind` on EVERY option — `open`, `won` or @@ -3605,6 +3690,9 @@ const ObjectSchemaBase = strictObject( // predicate names a declared single-value `select` sibling and one of its // options — the same door: only the object holds the sibling map. refuseDishonestReferenceDeletedWhen(object.name, object.fields, ctx); + // [#27945] Each `lookupAutofill` target is a writable field of this object, + // named once — the same door: only the object holds the field map. + refuseDishonestLookupAutofill(object.name, object.fields, ctx); // [#23809] Every option of the field `stageField` names declares its // lifecycle `kind` — the same door: only the object knows which field is // its stage. diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index bd94d0979ae..2b3fe1de4b4 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -41,6 +41,7 @@ import { controlledByParentMasterRefusal } from './data/controlled-by-parent-mas import { fieldGroupBodyTableRefusal, type NamedInboundRelationship } from './data/related-list-body-table'; import { externalBindingDatasourceRefusal } from './data/external-binding-datasource'; import { InlineGridColumnSchema } from './data/field.zod'; +import { AUDIT_FIELD_DEFS } from './data/injected-system-column-provenance'; import { pageComponentSlotPositions } from './ui/component.zod'; import { SeedSchema } from './data/seed.zod'; @@ -4087,6 +4088,133 @@ function collectHydratedInlineColumnErrors(config: ObjectStackDefinition): strin return errors; } +/** Target types that hold any single string, so any string-valued source fits them. */ +const LOOKUP_AUTOFILL_TEXT_TARGETS: ReadonlySet = new Set(['text', 'textarea', 'markdown', 'richtext', 'html']); +/** Source types whose value is one plain string. */ +const LOOKUP_AUTOFILL_STRING_SOURCES: ReadonlySet = new Set([ + 'text', 'textarea', 'markdown', 'richtext', 'html', 'email', 'url', 'phone', 'autonumber', +]); +/** The two amount types, which hold each other's value. */ +const LOOKUP_AUTOFILL_AMOUNT_TYPES: ReadonlySet = new Set(['number', 'currency']); +/** The relationship types, whose value is a record id of their `reference`. */ +const LOOKUP_AUTOFILL_RELATIONSHIP_TYPES: ReadonlySet = new Set(['lookup', 'master_detail']); +/** A `formula` source's value type, from its `returnType`. */ +const LOOKUP_AUTOFILL_FORMULA_VALUE_TYPE: Readonly> = { + number: 'number', currency: 'currency', text: 'text', boolean: 'boolean', date: 'date', +}; + +/** + * Why a `source` field's value cannot go into a `target` field, or `undefined` + * when it can (or when the source's value type is not known here). The rule is + * the stored value's shape, not the label: the same type and multiplicity, a + * string-valued source into a plain text target, `number` and `currency` into + * each other, and a relationship only into a relationship over the same + * object. + */ +function lookupAutofillTypeMismatch( + target: Record, + source: Record, +): string | undefined { + const targetType = target.type; + let sourceType = source.type; + if (sourceType === 'formula') { + sourceType = typeof source.returnType === 'string' ? LOOKUP_AUTOFILL_FORMULA_VALUE_TYPE[source.returnType] : undefined; + } + // A `summary`'s value type follows its operation and its rolled-up field, + // and a formula with no `returnType` declares none: not known here, so not + // judged — an unresolved type is not a wrong one. + if (typeof targetType !== 'string' || typeof sourceType !== 'string' || sourceType === 'summary') return undefined; + const sourceSpelling = source.type === 'formula' ? `a \`formula\` returning \`${sourceType}\`` : `a \`${sourceType}\` field`; + if ((target.multiple === true) !== (source.multiple === true)) { + return `${sourceSpelling}${source.multiple === true ? ' (`multiple: true`)' : ''} cannot fill a ` + + `\`${targetType}\` target${target.multiple === true ? ' (`multiple: true`)' : ''}: one holds a list, the other one value`; + } + if (LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(targetType) || LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(sourceType)) { + if (LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(targetType) && LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(sourceType) + && target.reference === source.reference) return undefined; + return `${sourceSpelling}${typeof source.reference === 'string' ? ` over '${source.reference}'` : ''} cannot fill a ` + + `\`${targetType}\` target${typeof target.reference === 'string' ? ` over '${target.reference}'` : ''}: ` + + 'a record id fills only a relationship over the same object'; + } + if (targetType === sourceType) return undefined; + if (LOOKUP_AUTOFILL_TEXT_TARGETS.has(targetType) && LOOKUP_AUTOFILL_STRING_SOURCES.has(sourceType)) return undefined; + if (LOOKUP_AUTOFILL_AMOUNT_TYPES.has(targetType) && LOOKUP_AUTOFILL_AMOUNT_TYPES.has(sourceType)) return undefined; + return `${sourceSpelling} cannot fill a \`${targetType}\` target: the target cannot hold its value`; +} + +/** + * [#27945] Each `lookupAutofill` pair's `source` names a field of the object + * the lookup references, which the picked record carries, and whose value the + * pair's target can hold. A source the referenced object does not declare + * arrives as nothing, an `internal` one is omitted from every read, and a value + * of the wrong shape is refused by the save the pick fed — so each would parse + * and fill nothing, or fill something the save then refuses (ADR-0049 declared + * = enforced). + * + * Judged here because only the stack holds the referenced object; the target + * half (a writable field of the declaring object, named once) is the object + * parse's own (`refuseDishonestLookupAutofill` in `data/object.zod.ts`), which + * has already passed by the time this pass runs. The type rule is + * {@link lookupAutofillTypeMismatch}. + * + * Resolution is against the stack's own `objects`, merged with its + * `objectExtensions` by name: a referenced object this stack does not declare + * is not judged — an unresolved source is not a wrong one, the stance of + * {@link collectHydratedInlineColumnErrors}. An undeclared source that is one + * of the audit-provenance columns the platform injects (`created_at`, …) is + * not judged either: every record carries it without a declaration. + */ +function collectLookupAutofillErrors(config: ObjectStackDefinition): string[] { + const errors: string[] = []; + const fieldsByObject = new Map>(); + for (const obj of config.objects ?? []) { + if (!isRecord(obj) || !isRecord(obj.fields)) continue; + fieldsByObject.set(obj.name, { ...fieldsByObject.get(obj.name), ...obj.fields }); + } + for (const ext of (config as { objectExtensions?: unknown }).objectExtensions as unknown[] ?? []) { + if (!isRecord(ext) || typeof ext.extend !== 'string' || !isRecord(ext.fields)) continue; + fieldsByObject.set(ext.extend, { ...fieldsByObject.get(ext.extend), ...ext.fields }); + } + + for (const [objectName, fields] of fieldsByObject) { + for (const [fieldName, field] of Object.entries(fields)) { + if (!isRecord(field) || !Array.isArray(field.lookupAutofill) || typeof field.reference !== 'string') continue; + const referenced = fieldsByObject.get(field.reference); + if (!referenced) continue; + field.lookupAutofill.forEach((pair: unknown, i: number) => { + if (!isRecord(pair) || typeof pair.source !== 'string') return; + const where = `Object '${objectName}' field '${fieldName}' lookupAutofill[${i}].source`; + const source = hasOwnKey(referenced, pair.source) ? referenced[pair.source] : undefined; + if (!isRecord(source)) { + if (hasOwnKey(AUDIT_FIELD_DEFS, pair.source)) return; + errors.push( + `${where}: '${pair.source}' is not a field of '${field.reference}', the object this lookup references, ` + + `so a pick has no value to copy. Name a field '${field.reference}' declares.`, + ); + return; + } + if (source.internal === true) { + errors.push( + `${where}: '${field.reference}.${pair.source}' is \`internal\`, so no read returns it and a pick has no ` + + 'value to copy. Name a field the record carries.', + ); + return; + } + const target = typeof pair.target === 'string' && hasOwnKey(fields, pair.target) ? fields[pair.target] : undefined; + if (!isRecord(target)) return; + const mismatch = lookupAutofillTypeMismatch(target, source); + if (mismatch !== undefined) { + errors.push( + `${where}: '${field.reference}.${pair.source}' is ${mismatch} ('${objectName}.${String(pair.target)}'). ` + + 'Pair the target with a source of the same type.', + ); + } + }); + } + } + return errors; +} + /** * [#24190] The child object a `record:line_items` block lists, resolved the * way objectui resolves it before `LineItemsPanel` hydrates its columns from @@ -4843,6 +4971,10 @@ function validateCrossReferences( // #24190 the line-items block's), the column schema's own verdict. errors.push(...collectHydratedInlineColumnErrors(config)); + // Validate each lookup-pick autofill source against the referenced object + // (#27945) — the half the object's own parse cannot see. + errors.push(...collectLookupAutofillErrors(config)); + // Validate seed data → object references. ARTIFACT-SCOPED (#18202). errors.push(...collectSeedDataObjectErrors(config, artifactScope)); From 58878394ae986eed868dae19f02f4efbdb1788af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:17:24 +0000 Subject: [PATCH 2/7] wip(spec): lookupAutofill tests Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyU9JHYh6atGVnEXr3 --- .../src/data/field-lookup-autofill.test.ts | 255 ++++++++++++++++++ packages/spec/src/stack.zod.ts | 22 +- 2 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 packages/spec/src/data/field-lookup-autofill.test.ts diff --git a/packages/spec/src/data/field-lookup-autofill.test.ts b/packages/spec/src/data/field-lookup-autofill.test.ts new file mode 100644 index 00000000000..dfe308c0192 --- /dev/null +++ b/packages/spec/src/data/field-lookup-autofill.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { FieldSchema, InlineGridColumnSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { defineStack } from '../stack.zod'; + +// --------------------------------------------------------------------------- +// [#27945] `lookupAutofill: [{ target, source, overwrite? }]` on a single-value +// relationship field: a pick in the lookup fills `target` (a field of THIS +// object) from `source` (a field of the referenced object). Three doors judge +// it, and each refusal is pinned here by its code and its path: +// +// - field half (`FieldSchema`): only on a single-value `lookup` / +// `master_detail`; a pair is a closed shape of snake_case names; +// - target half (`ObjectSchema`): a declared field of this object, not the +// lookup itself, not `readonly`, not computed, named once; +// - source half (`defineStack`'s cross-reference pass): a declared field of +// the referenced object, not `internal`, whose value the target can hold. +// +// The producer's own shape (HotCRM's quote line: `unit_price` from the picked +// product's `list_price`) parses clean, and the grid column key `autofill` +// keeps its contract (the control at the end). +// --------------------------------------------------------------------------- + +const manifest = { + id: 'com.example.lookupautofill', + name: 'lookup-autofill-test', + version: '1.0.0', + type: 'app' as const, +}; + +const product = (extra: Record = {}) => ({ + name: 'crm_product', + label: 'Product', + fields: { + name: { type: 'text', label: 'Name' }, + description: { type: 'textarea', label: 'Description' }, + support_email: { type: 'email', label: 'Support Email' }, + list_price: { type: 'currency', label: 'List Price' }, + weight: { type: 'number', label: 'Weight' }, + is_active: { type: 'boolean', label: 'Active' }, + category: { type: 'lookup', label: 'Category', reference: 'crm_category' }, + tags: { type: 'multiselect', label: 'Tags', options: [{ label: 'Alpha', value: 'alpha' }] }, + cost_token: { type: 'text', label: 'Cost Token', internal: true }, + margin: { type: 'formula', label: 'Margin', returnType: 'currency', expression: 'record.list_price * 0.2' }, + ...extra, + }, +}); + +const category = { name: 'crm_category', label: 'Category', fields: { name: { type: 'text', label: 'Name' } } }; +const region = { name: 'crm_region', label: 'Region', fields: { name: { type: 'text', label: 'Name' } } }; + +const line = (pairs: unknown, productField: Record = {}) => ({ + name: 'crm_quote_line_item', + label: 'Quote Line Item', + fields: { + crm_product: { type: 'lookup', label: 'Product', reference: 'crm_product', lookupAutofill: pairs, ...productField }, + unit_price: { type: 'currency', label: 'Sales Price' }, + list_price: { type: 'currency', label: 'List Price', readonly: true }, + quantity: { type: 'number', label: 'Quantity' }, + notes: { type: 'text', label: 'Notes' }, + description: { type: 'textarea', label: 'Description' }, + is_taxable: { type: 'boolean', label: 'Taxable' }, + category: { type: 'lookup', label: 'Category', reference: 'crm_category' }, + region: { type: 'lookup', label: 'Region', reference: 'crm_region' }, + tags: { type: 'multiselect', label: 'Tags', options: [{ label: 'Alpha', value: 'alpha' }] }, + subtotal: { type: 'formula', label: 'Subtotal', returnType: 'currency', expression: 'record.quantity * record.unit_price' }, + line_no: { type: 'autonumber', label: 'Line', autonumberFormat: 'L-{0000}' }, + net_price: { type: 'currency', label: 'Net', expression: 'record.unit_price * 0.9' }, + }, +}); + +/** HotCRM's quote line, on the overwrite rule's default: the negotiated price is filled while empty. */ +const HOTCRM_PAIRS = [{ target: 'unit_price', source: 'list_price' }]; + +type Issue = { code: string; path: PropertyKey[]; message: string }; + +/** The one issue a refused value must carry. */ +function refusedOnce(result: { success: boolean; error?: { issues: Issue[] } }): Issue { + expect(result.success, 'the declaration must NOT parse clean').toBe(false); + expect(result.error!.issues).toHaveLength(1); + return result.error!.issues[0]!; +} + +/** The object's one located target refusal. */ +function targetRefusedAt(pairs: unknown, index: number): Issue { + const issue = refusedOnce(ObjectSchema.safeParse(line(pairs)) as never); + expect([issue.code, issue.path]).toEqual(['custom', ['fields', 'crm_product', 'lookupAutofill', index, 'target']]); + return issue; +} + +const build = (stack: unknown) => defineStack(stack as Parameters[0]); + +type Refusal = { code?: string; status?: number; issues?: unknown[] }; + +/** The stack's one cross-reference finding, at the pair's source. */ +function sourceRefusedAt(objects: unknown[], index: number): string { + let refusal: Refusal | undefined; + try { + build({ manifest, objects }); + } catch (error) { + refusal = error as Refusal; + } + expect(refusal, 'expected defineStack to REFUSE this stack, and it accepted it').toBeDefined(); + expect(refusal!.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refusal!.status).toBe(422); + expect(refusal!.issues).toHaveLength(1); + const finding = String(refusal!.issues![0]); + expect(finding.startsWith(`Object 'crm_quote_line_item' field 'crm_product' lookupAutofill[${index}].source: `)).toBe(true); + return finding; +} + +const stackOf = (pairs: unknown) => ({ manifest, objects: [category, region, product(), line(pairs)] }); + +describe('`lookupAutofill` — field half (FieldSchema)', () => { + it('accepts the pairs on a `lookup` and on a `master_detail` (control)', () => { + for (const type of ['lookup', 'master_detail']) { + const parsed = FieldSchema.safeParse({ type, reference: 'crm_product', lookupAutofill: [{ target: 'unit_price', source: 'list_price', overwrite: true }] }); + expect(parsed.success).toBe(true); + } + }); + + it('refuses the key on a field that is not a relationship', () => { + const issue = refusedOnce(FieldSchema.safeParse({ type: 'text', lookupAutofill: HOTCRM_PAIRS }) as never); + expect([issue.code, issue.path]).toEqual(['custom', ['lookupAutofill']]); + expect(issue.message.startsWith("`lookupAutofill` is only valid on `type: 'lookup'` or `type: 'master_detail'`")).toBe(true); + }); + + it('refuses the key on a multi-value relationship', () => { + const issue = refusedOnce(FieldSchema.safeParse({ type: 'lookup', reference: 'crm_product', multiple: true, lookupAutofill: HOTCRM_PAIRS }) as never); + expect([issue.code, issue.path]).toEqual(['custom', ['lookupAutofill']]); + expect(issue.message.startsWith('`lookupAutofill` is not valid on a multi-value relationship (`multiple: true`)')).toBe(true); + }); + + it('refuses an empty list, a non-snake_case name and an unknown key in a pair', () => { + const empty = refusedOnce(FieldSchema.safeParse({ type: 'lookup', reference: 'crm_product', lookupAutofill: [] }) as never); + expect([empty.code, empty.path]).toEqual(['too_small', ['lookupAutofill']]); + + const casing = refusedOnce(FieldSchema.safeParse({ type: 'lookup', reference: 'crm_product', lookupAutofill: [{ target: 'UnitPrice', source: 'list_price' }] }) as never); + expect([casing.code, casing.path]).toEqual(['invalid_format', ['lookupAutofill', 0, 'target']]); + + const alias = refusedOnce(FieldSchema.safeParse({ type: 'lookup', reference: 'crm_product', lookupAutofill: [{ target: 'unit_price', from: 'list_price', source: 'list_price' }] }) as never); + expect([alias.code, alias.path]).toEqual(['unrecognized_keys', ['lookupAutofill', 0]]); + expect(alias.message).toContain('`source`'); + }); +}); + +describe('`lookupAutofill` — target half (ObjectSchema)', () => { + it('accepts the producer shape, and each target kind a pick may fill (control)', () => { + expect(ObjectSchema.safeParse(line(HOTCRM_PAIRS)).success).toBe(true); + expect(ObjectSchema.safeParse(line([ + { target: 'unit_price', source: 'list_price' }, + { target: 'description', source: 'description', overwrite: true }, + { target: 'category', source: 'category' }, + ])).success).toBe(true); + }); + + it('refuses the lookup itself as a target', () => { + expect(targetRefusedAt([{ target: 'crm_product', source: 'name' }], 0).message).toContain('which is the lookup itself'); + }); + + it('refuses a target the object does not declare', () => { + expect(targetRefusedAt([{ target: 'sales_price', source: 'list_price' }], 0).message).toContain('declares no field `sales_price`'); + }); + + it('refuses a `readonly` target', () => { + expect(targetRefusedAt([{ target: 'list_price', source: 'list_price' }], 0).message).toContain('a `readonly` field'); + }); + + it('refuses a computed target — formula, autonumber, and a stored field with an `expression`', () => { + expect(targetRefusedAt([{ target: 'subtotal', source: 'list_price' }], 0).message).toContain('a `formula` field'); + expect(targetRefusedAt([{ target: 'line_no', source: 'name' }], 0).message).toContain('a `autonumber` field'); + expect(targetRefusedAt([{ target: 'net_price', source: 'list_price' }], 0).message).toContain('computed from its `expression`'); + }); + + it('refuses a target named by an earlier pair, at the later pair', () => { + const issue = targetRefusedAt([ + { target: 'unit_price', source: 'list_price' }, + { target: 'unit_price', source: 'margin', overwrite: true }, + ], 1); + expect(issue.message).toContain('an earlier pair of this field already fills'); + }); + + it('refuses at the save door too (the registered `object` type schema)', () => { + const objectType = getMetadataTypeSchema('object')!; + const issue = refusedOnce(objectType.safeParse(line([{ target: 'list_price', source: 'list_price' }])) as never); + expect([issue.code, issue.path]).toEqual(['custom', ['fields', 'crm_product', 'lookupAutofill', 0, 'target']]); + }); +}); + +describe('`lookupAutofill` — source half (defineStack cross-reference)', () => { + it('accepts the producer shape and every compatible pairing (control)', () => { + expect(() => build(stackOf(HOTCRM_PAIRS))).not.toThrow(); + expect(() => build(stackOf([ + { target: 'unit_price', source: 'margin' }, // a currency formula into currency + { target: 'quantity', source: 'weight' }, // number into number + { target: 'notes', source: 'support_email' }, // a string into plain text + { target: 'description', source: 'description', overwrite: true }, + { target: 'is_taxable', source: 'is_active' }, + { target: 'category', source: 'category' }, // a relationship over the same object + { target: 'tags', source: 'tags' }, + ]))).not.toThrow(); + // `number` and `currency` hold each other's value. + expect(() => build(stackOf([{ target: 'quantity', source: 'list_price' }]))).not.toThrow(); + }); + + it('does not judge a referenced object the stack does not declare, nor an injected audit column', () => { + expect(() => build({ manifest, objects: [category, region, line([{ target: 'notes', source: 'anything' }])] })).not.toThrow(); + expect(() => build(stackOf([{ target: 'notes', source: 'created_at' }]))).not.toThrow(); + }); + + it('refuses a source the referenced object does not declare', () => { + const finding = sourceRefusedAt([category, region, product(), line([{ target: 'unit_price', source: 'price' }])], 0); + expect(finding).toContain("'price' is not a field of 'crm_product'"); + }); + + it('refuses an `internal` source', () => { + const finding = sourceRefusedAt([category, region, product(), line([{ target: 'notes', source: 'cost_token' }])], 0); + expect(finding).toContain("'crm_product.cost_token' is `internal`"); + }); + + it('refuses a source whose value the target cannot hold', () => { + const objects = (pairs: unknown) => [category, region, product(), line(pairs)]; + expect(sourceRefusedAt(objects([{ target: 'unit_price', source: 'name' }]), 0)) + .toContain("cannot fill 'crm_quote_line_item.unit_price' — the target cannot hold the source's value (`text` → `currency`)"); + expect(sourceRefusedAt(objects([{ target: 'unit_price', source: 'list_price' }, { target: 'is_taxable', source: 'weight' }]), 1)) + .toContain("the target cannot hold the source's value (`number` → `boolean`)"); + expect(sourceRefusedAt(objects([{ target: 'region', source: 'category' }]), 0)) + .toContain("a record id fills only a relationship over the same object (`lookup` over 'crm_category' → `lookup` over 'crm_region')"); + expect(sourceRefusedAt(objects([{ target: 'notes', source: 'tags' }]), 0)) + .toContain('one holds a list and the other a single value (`multiselect` → `text`)'); + expect(sourceRefusedAt(objects([{ target: 'is_taxable', source: 'margin' }]), 0)) + .toContain("the target cannot hold the source's value (`formula` returning `currency` → `boolean`)"); + }); +}); + +describe('control: the grid column key `autofill` keeps its contract', () => { + it('a lookup column still takes `autofill` on and off, and its describe is unchanged', () => { + for (const autofill of [true, false]) { + expect(InlineGridColumnSchema.safeParse({ name: 'crm_product', type: 'lookup', reference: 'crm_product', autofill }).success).toBe(true); + } + expect(InlineGridColumnSchema.safeParse({ name: 'crm_product' }).success).toBe(true); + const shape = (InlineGridColumnSchema as unknown as { shape: Record }).shape; + expect(shape.autofill!.description).toBe( + "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.", + ); + }); + + it('the declared pairs live on the field: a grid column refuses `lookupAutofill`', () => { + const issue = refusedOnce(InlineGridColumnSchema.safeParse({ name: 'crm_product', lookupAutofill: HOTCRM_PAIRS }) as never); + expect(issue.code).toBe('unrecognized_keys'); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 2b3fe1de4b4..03dbd16586b 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -4098,6 +4098,8 @@ const LOOKUP_AUTOFILL_STRING_SOURCES: ReadonlySet = new Set([ const LOOKUP_AUTOFILL_AMOUNT_TYPES: ReadonlySet = new Set(['number', 'currency']); /** The relationship types, whose value is a record id of their `reference`. */ const LOOKUP_AUTOFILL_RELATIONSHIP_TYPES: ReadonlySet = new Set(['lookup', 'master_detail']); +/** The option types that hold a list without declaring `multiple`. */ +const LOOKUP_AUTOFILL_INHERENTLY_MULTI_TYPES: ReadonlySet = new Set(['multiselect', 'checkboxes', 'tags']); /** A `formula` source's value type, from its `returnType`. */ const LOOKUP_AUTOFILL_FORMULA_VALUE_TYPE: Readonly> = { number: 'number', currency: 'currency', text: 'text', boolean: 'boolean', date: 'date', @@ -4124,22 +4126,22 @@ function lookupAutofillTypeMismatch( // and a formula with no `returnType` declares none: not known here, so not // judged — an unresolved type is not a wrong one. if (typeof targetType !== 'string' || typeof sourceType !== 'string' || sourceType === 'summary') return undefined; - const sourceSpelling = source.type === 'formula' ? `a \`formula\` returning \`${sourceType}\`` : `a \`${sourceType}\` field`; - if ((target.multiple === true) !== (source.multiple === true)) { - return `${sourceSpelling}${source.multiple === true ? ' (`multiple: true`)' : ''} cannot fill a ` - + `\`${targetType}\` target${target.multiple === true ? ' (`multiple: true`)' : ''}: one holds a list, the other one value`; + const isList = (def: Record, type: string): boolean => + def.multiple === true || LOOKUP_AUTOFILL_INHERENTLY_MULTI_TYPES.has(type); + const pairing = `\`${String(source.type)}\`${source.type === 'formula' ? ` returning \`${sourceType}\`` : ''} → \`${targetType}\``; + if (isList(target, targetType) !== isList(source, sourceType)) { + return `one holds a list and the other a single value (${pairing})`; } if (LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(targetType) || LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(sourceType)) { if (LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(targetType) && LOOKUP_AUTOFILL_RELATIONSHIP_TYPES.has(sourceType) && target.reference === source.reference) return undefined; - return `${sourceSpelling}${typeof source.reference === 'string' ? ` over '${source.reference}'` : ''} cannot fill a ` - + `\`${targetType}\` target${typeof target.reference === 'string' ? ` over '${target.reference}'` : ''}: ` - + 'a record id fills only a relationship over the same object'; + const over = (def: Record): string => (typeof def.reference === 'string' ? ` over '${def.reference}'` : ''); + return `a record id fills only a relationship over the same object (${pairing.replace(' → ', `${over(source)} → `)}${over(target)})`; } if (targetType === sourceType) return undefined; if (LOOKUP_AUTOFILL_TEXT_TARGETS.has(targetType) && LOOKUP_AUTOFILL_STRING_SOURCES.has(sourceType)) return undefined; if (LOOKUP_AUTOFILL_AMOUNT_TYPES.has(targetType) && LOOKUP_AUTOFILL_AMOUNT_TYPES.has(sourceType)) return undefined; - return `${sourceSpelling} cannot fill a \`${targetType}\` target: the target cannot hold its value`; + return `the target cannot hold the source's value (${pairing})`; } /** @@ -4205,8 +4207,8 @@ function collectLookupAutofillErrors(config: ObjectStackDefinition): string[] { const mismatch = lookupAutofillTypeMismatch(target, source); if (mismatch !== undefined) { errors.push( - `${where}: '${field.reference}.${pair.source}' is ${mismatch} ('${objectName}.${String(pair.target)}'). ` - + 'Pair the target with a source of the same type.', + `${where}: '${field.reference}.${pair.source}' cannot fill '${objectName}.${String(pair.target)}' — ` + + `${mismatch}. Pair the target with a source whose value it holds.`, ); } }); From 6ea314a64ad170838ec7b4ec4373d5f4cee820cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:25:44 +0000 Subject: [PATCH 3/7] wip(spec): lookupAutofill liveness row, form ledger, changeset, regenerated artefacts Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyU9JHYh6atGVnEXr3 --- .changeset/27945-lookup-pick-autofill.md | 18 ++++++++++++++ content/docs/references/data/field.mdx | 9 +++++++ content/docs/references/data/object.mdx | 2 ++ content/docs/references/system/migration.mdx | 2 ++ .../data.md | 12 +++++----- packages/spec/authorable-surface/data.json | 1 + packages/spec/liveness/field.json | 24 +++++++++++++++++++ packages/spec/liveness/state-counts/field.md | 2 +- .../metadata-form-zod-reconciliation.test.ts | 10 ++++++++ 9 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 .changeset/27945-lookup-pick-autofill.md diff --git a/.changeset/27945-lookup-pick-autofill.md b/.changeset/27945-lookup-pick-autofill.md new file mode 100644 index 00000000000..024b3d019c4 --- /dev/null +++ b/.changeset/27945-lookup-pick-autofill.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): a lookup field declares which fields a pick fills (`lookupAutofill`) + +Clause-②: yes (widening) +Metadata-protocol: added + +A single-value `lookup` / `master_detail` field can now declare `lookupAutofill`: a list of `{ target, source, overwrite? }` pairs. A pick in the lookup copies the picked record's `source` field (a field of the referenced object) into the record's `target` field. A quote line's product lookup declares `[{ target: 'unit_price', source: 'list_price' }]`, so the sales price can fill from the product as soon as the product is picked, instead of only after save. + +- **The overwrite rule.** A pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in, so a typed value, or one the record already stored, stays. A pair that declares `overwrite: true` replaces the target on every pick. +- **One home for the form and the grid.** The declaration lives on the field, so the record form and a line-item grid column over the field both read it. The grid column key `autofill` is unchanged: it still governs only the grid's same-named copy, on by default. A declared pair takes its target out of that copy. +- **Client-side only.** It fills the form. Nothing on the write path reads it, so an import or API write that sets the lookup fills nothing. +- **Refused at parse:** the key on anything but a single-value relationship; a target that is the lookup itself, not a field of the object, `readonly`, computed (`formula`, `summary`, `autonumber`, or a field with an `expression`), or named twice; an unknown key in a pair. +- **Refused by `os validate` / `os build`** (`STACK_CROSS_REFERENCE_INVALID`): a source the referenced object does not declare, an `internal` source, and a source whose value the target cannot hold (the same type and multiplicity, a string into a plain text field, `number` and `currency` into each other, or a relationship over the same object). + +The form and grid that write the declared targets on pick ship in a follow-up. Until then the declaration is checked and stored, and fills nothing. Nothing to do for existing metadata. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 000b5c65642..bc1b1a908a6 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -85,6 +85,7 @@ const result = CurrencyConfigSchema.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -223,6 +224,14 @@ const result = CurrencyConfigSchema.parse(data); | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | 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** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | 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. | +### Nested Shape: `Field.lookupAutofill[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **target** | `string` | ✅ | Field of THIS object the pick fills (snake_case machine name). | +| **source** | `string` | ✅ | Field of the picked record — a field of this lookup's `reference` object — whose value is copied (snake_case machine name). | +| **overwrite** | `boolean` | optional | TRUE: the pick replaces whatever the target holds, a typed value included. Absent or FALSE: the pick fills the target only while it is empty or still holds the value an earlier pick filled in. | + ### Nested Shape: `Field.summaryOperations` | Property | Type | Required | Description | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 750b3ad0b84..2b07c2a3411 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -263,6 +263,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -626,6 +627,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 61d41509065..6816f1b355f 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -97,6 +97,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -528,6 +529,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 6d67334be3f..4af374d9628 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 154 | 88 | 2 | 0 | 64 | +| `data/` | 155 | 89 | 2 | 0 | 64 | ## `data/` — sites @@ -45,7 +45,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `driver/turso.zod.ts` | 2 | | `external-catalog.zod.ts` | 4 | | `field-value.zod.ts` | 4 | -| `field.zod.ts` | 13 | +| `field.zod.ts` | 14 | | `filter.zod.ts` | 13 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 8 | @@ -57,7 +57,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 2 | | `validation.zod.ts` | 6 | -| **total** | **154** | +| **total** | **155** | ## `data/` — open @@ -65,7 +65,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**64 strip of 154**, in 10 file(s). +**64 strip of 155**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -74,12 +74,12 @@ over it is here. | `driver-sql.zod.ts` | 2 | 2 | | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | -| `field.zod.ts` | 1 | 13 | +| `field.zod.ts` | 1 | 14 | | `filter.zod.ts` | 11 | 13 | | `hook.zod.ts` | 5 | 8 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **64** | **154** | +| **total** | **64** | **155** | | Bucket | Sites | |---|---| diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 9b3731b9d4e..26db0a28b15 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -314,6 +314,7 @@ "data/Field:internal", "data/Field:label", "data/Field:language", + "data/Field:lookupAutofill", "data/Field:lookupColumns", "data/Field:lookupFilters", "data/Field:lookupPageSize", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 7b13fc4f763..d10ce03b2a1 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -373,6 +373,30 @@ "verifiedAt": "2026-10-16", "note": "objectui LookupField — opt-in inline quick-create (dataSource.create from typed text). Read camelCase-only at objectui @daeb77fc84: packages/fields/src/widgets/LookupField.tsx#explicitAllowCreate (objectui#12157 retired the `allow_create` read)." }, + "lookupAutofill": { + "status": "planned", + "verifiedAt": "2026-10-30", + "evidenceScope": "in-repo", + "evidence": "packages/spec/src/data/field.zod.ts#lookupAutofill (the parse-time door, field half: the FieldSchema superRefine refuses the key off a single-value `lookup` / `master_detail`); packages/spec/src/data/object.zod.ts#refuseDishonestLookupAutofill (the parse-time door, target half: each target a declared, author-written field of the same object, not the lookup itself, named once); packages/spec/src/stack.zod.ts#collectLookupAutofillErrors (the `os validate` / `os build` door, source half: a declared, non-`internal` field of the referenced object whose value the target holds); packages/spec/src/data/field-lookup-autofill.test.ts (the accept and refuse pins)", + "note": "PLANNED — declared ahead of its one reader by the card's own sequencing (#27945 is the spec half; the ui half, the record form's lookup widget and the line-item grid writing the declared targets on pick, is a separate domain:ui card). PRODUCER: HotCRM's quote line item (product lookup, `unit_price` from the product's `list_price`), today filled only after save by a server hook. CONSUMER: ui/packages/fields LookupField (the form) and ui/packages/fields/src/widgets/GridField.tsx#lookupAutofillPatch (the grid), which today copies only same-named fields under the column key `autofill`. Measured in this repo at 3a2f4c00: no reader of `lookupAutofill` anywhere outside packages/spec, ui/ included (the key is new). No `authorWarn`, on the `referenceDeletedWhen` precedent: the authoring half (parse, target and source checks) is enforced now, an author who sets it today re-authors nothing when the reader lands, and a warned field row would reopen the lint's field walk for one key. Flip to `live` when the ui card lands, citing both read points.", + "children": { + "target": { + "status": "planned", + "verifiedAt": "2026-10-30", + "note": "The field of the declaring object a pick fills. Judged at parse by the container's evidence (snake_case; declared on the same object, not the lookup itself, not readonly, not computed, named once); written by nothing until the ui card's form and grid read the pairs. Shares the container's verdict and its flip." + }, + "source": { + "status": "planned", + "verifiedAt": "2026-10-30", + "note": "The field of the picked record whose value is copied. Judged at `os validate` / `os build` by the container's evidence (declared on the referenced object, not internal, a value the target holds); read by nothing until the ui card lands. Shares the container's verdict and its flip." + }, + "overwrite": { + "status": "planned", + "verifiedAt": "2026-10-30", + "note": "Opt-in to replace a value the target already holds on every pick; absent, a pick fills only an empty target or one an earlier pick filled. Read by nothing until the ui card lands. Shares the container's verdict and its flip." + } + } + }, "searchable": { "status": "live", "evidence": "ui/packages/plugin-dashboard/src/WidgetConfigPanel.tsx (:458 in objectui, commit not pinned)", diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 311575be0a5..bef133861bf 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 100 | 0 | 0 | 1 | 5 | 106 | +| `field` | 100 | 0 | 0 | 1 | 8 | 109 | diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 9897e447d00..8b68ca4d926 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -365,6 +365,16 @@ const LEDGER: ReadonlyArray = [ key: 'conditionalFormatting', why: "planned, not yet rendered — a field's cell formatting rules (`[{ condition, style }]`, the list view's own rule element) are validated at parse and their CEL is judged at the authoring doors (it parses, it reads only `value` and `record`), but no renderer styles a cell from them until objectui's cell renderers read them, after the spec publishes. A form control offered today would let an author set a style nothing paints", }, + // #27945 — a lookup's pick autofill lands spec-first the same way: `planned` + // in the liveness ledger, read by no form or grid until the ui card's lookup + // widget writes the declared targets on pick. + { + kind: 'omit', + type: 'field', + path: ROOT_PATH, + key: 'lookupAutofill', + why: "planned, not yet read — a lookup's pick autofill pairs (`[{ target, source, overwrite? }]`) are validated at parse (a single-value relationship; each target a writable field of the same object, named once) and their sources at `os validate` / `os build`, but no form or grid fills a field from them until the ui card's lookup widget writes the declared targets on pick. A form control offered today would let an author declare a fill nothing performs", + }, { kind: 'omit', type: 'view', From 4c70e71e5277beaeb66da3e67ee64e965bb5dfdc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:05:59 +0000 Subject: [PATCH 4/7] chore(spec): regenerate the object reference page from the merged tree Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyU9JHYh6atGVnEXr3 --- content/docs/references/data/object.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 2b07c2a3411..92e65b6221f 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -383,7 +383,7 @@ const result = ApiMethod.parse(data); | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | From 117ff3b6e9d201461e03dd3b3b1573a0bfdf4c07 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:30:26 +0000 Subject: [PATCH 5/7] fix(spec,driver-sql,metadata-core): classify lookupAutofill in the every-key tables; describe states the overwrite default Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyU9JHYh6atGVnEXr3 --- .changeset/27945-lookup-pick-autofill.md | 5 ++- .../src/builtin-column-collision.ts | 1 + .../src/object-schema-fls-references.test.ts | 32 +++++++++++++++++++ .../src/object-schema-fls-references.ts | 24 ++++++++++++++ ...l.meta-types-degenerate-derivation.test.ts | 4 ++- packages/spec/src/data/field.zod.ts | 2 +- 6 files changed, 65 insertions(+), 3 deletions(-) diff --git a/.changeset/27945-lookup-pick-autofill.md b/.changeset/27945-lookup-pick-autofill.md index 024b3d019c4..23f028a8a3d 100644 --- a/.changeset/27945-lookup-pick-autofill.md +++ b/.changeset/27945-lookup-pick-autofill.md @@ -1,5 +1,7 @@ --- '@objectstack/spec': minor +'@objectstack/driver-sql': minor +'@objectstack/metadata-core': minor --- feat(spec): a lookup field declares which fields a pick fills (`lookupAutofill`) @@ -9,10 +11,11 @@ Metadata-protocol: added A single-value `lookup` / `master_detail` field can now declare `lookupAutofill`: a list of `{ target, source, overwrite? }` pairs. A pick in the lookup copies the picked record's `source` field (a field of the referenced object) into the record's `target` field. A quote line's product lookup declares `[{ target: 'unit_price', source: 'list_price' }]`, so the sales price can fill from the product as soon as the product is picked, instead of only after save. -- **The overwrite rule.** A pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in, so a typed value, or one the record already stored, stays. A pair that declares `overwrite: true` replaces the target on every pick. +- **The overwrite rule.** By default (`overwrite` absent or false), a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in, so a typed value, or one the record already stored, stays. A pair that declares `overwrite: true` replaces the target on every pick. - **One home for the form and the grid.** The declaration lives on the field, so the record form and a line-item grid column over the field both read it. The grid column key `autofill` is unchanged: it still governs only the grid's same-named copy, on by default. A declared pair takes its target out of that copy. - **Client-side only.** It fills the form. Nothing on the write path reads it, so an import or API write that sets the lookup fills nothing. - **Refused at parse:** the key on anything but a single-value relationship; a target that is the lookup itself, not a field of the object, `readonly`, computed (`formula`, `summary`, `autonumber`, or a field with an `expression`), or named twice; an unknown key in a pair. - **Refused by `os validate` / `os build`** (`STACK_CROSS_REFERENCE_INVALID`): a source the referenced object does not declare, an `internal` source, and a source whose value the target cannot hold (the same type and multiplicity, a string into a plain text field, `number` and `currency` into each other, or a relationship over the same object). +- **Every-key tables classify the new key.** `@objectstack/driver-sql` classifies it `presentation` in `FIELD_KEY_STORAGE_CLASS`: no column DDL reads it. `@objectstack/metadata-core`'s field-level-security scrub (`FIELD_REFERENCE_POSITIONS`) drops a pair whose `target` is a field the caller cannot read, and deletes a list left empty. Each `source` names a field of the referenced object, which that object's own projection governs, so it is kept. The form and grid that write the declared targets on pick ship in a follow-up. Until then the declaration is checked and stored, and fills nothing. Nothing to do for existing metadata. diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts index ca3aa5a1aa3..43bc2d4f5a6 100644 --- a/packages/drivers/driver-sql/src/builtin-column-collision.ts +++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts @@ -147,6 +147,7 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> = lookupFilters: 'presentation', dependsOn: 'presentation', allowCreate: 'presentation', + lookupAutofill: 'presentation', // a lookup's pick autofill pairs (#27945), read by the form and grid on pick — never by `createColumn` // Read by the WRITE path, never by the DDL: `initObjects` registers an // autonumber generator off `type: 'auto_number'` without skipping builtin // names, so the format an author declares is honoured rather than discarded. diff --git a/packages/metadata-core/src/object-schema-fls-references.test.ts b/packages/metadata-core/src/object-schema-fls-references.test.ts index 922e89cf2e1..14e9ee4c6d4 100644 --- a/packages/metadata-core/src/object-schema-fls-references.test.ts +++ b/packages/metadata-core/src/object-schema-fls-references.test.ts @@ -23,6 +23,7 @@ import { FIELD_REFERENCE_POSITIONS, INLINE_COLUMN_POSITIONS, LIST_COLUMN_POSITIONS, + LOOKUP_AUTOFILL_PAIR_POSITIONS, OBJECT_REFERENCE_POSITIONS, mentionsDenied, } from './object-schema-fls-references.js'; @@ -404,6 +405,29 @@ describe('[ADR-0106 D1] references to a denied field — inside a READABLE field expect(served.fields.title).not.toHaveProperty('inlineAmountField'); }); + it('drops a `lookupAutofill` pair whose target is denied, and keeps a source named on the referenced object', () => { + // #27945 — `target` is a field of THIS object; `source` names a field of + // the lookup's `reference` object, which this projection cannot judge. + const served = mask({ + name: 'thing', + fields: fieldsWith({ + type: 'lookup', + reference: 'other', + lookupAutofill: [ + { target: 'id', source: 'secret_score' }, + { target: 'secret_score', source: 'price', overwrite: true }, + ], + }), + }); + expect(served.fields.title.lookupAutofill).toEqual([{ target: 'id', source: 'secret_score' }]); + + const allDenied = mask({ + name: 'thing', + fields: fieldsWith({ type: 'lookup', reference: 'other', lookupAutofill: [{ target: 'secret_score', source: 'price' }] }), + }); + expect(allDenied.fields.title).not.toHaveProperty('lookupAutofill'); + }); + it('keeps a readable inline amount field, and deletes a grid left with no column', () => { const served = mask({ name: 'thing', @@ -731,6 +755,14 @@ describe('[ADR-0106 D1] every position is classified — closed against the live expect(unclassified, 'classify each new InlineGridColumnSchema key in INLINE_COLUMN_POSITIONS').toEqual([]); }); + // A lookup autofill pair's `target` names a field of this object (#27945). + it('classifies every lookupAutofill pair key, and nothing it no longer declares', () => { + const list = (FieldSchema.shape as unknown as Record).lookupAutofill!; + const keys = declaredKeys(list.unwrap().element); + expect(keys.filter((key) => !(key in LOOKUP_AUTOFILL_PAIR_POSITIONS)), 'classify each new lookupAutofill pair key').toEqual([]); + expect(Object.keys(LOOKUP_AUTOFILL_PAIR_POSITIONS).filter((key) => !keys.includes(key))).toEqual([]); + }); + // A list column's nested pointers (`prefix.field`, `summary.field`) name // fields of this object: a new facet must be classified before it ships. for (const [label, schema, table] of [ diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index 446b7ea6c9f..0e29f9e3366 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -589,6 +589,27 @@ const deletedWhen: Scrub = (value, denied) => { return typeof field === 'string' && namesDeniedField(field, denied) ? REMOVE : value; }; +/** + * [ADR-0106 D1] One `lookupAutofill` pair `{ target, source, overwrite? }` + * (#27945). `target` is a field of THIS object the pick fills; `source` names a + * field of the lookup's `reference` object — that object's projection governs + * it, and this scrub cannot verify it, so it is kept. A pair whose `target` is + * denied is dropped whole: a bare `source` would fill a column the caller is not + * served. An emptied list is deleted by {@link arrayOf}. + */ +export const LOOKUP_AUTOFILL_PAIR_POSITIONS: Readonly> = { + target: pointer, + source: keep, + overwrite: keep, +}; + +const lookupAutofillPair: Scrub = (value, denied) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); + const rec = value as Record; + if (typeof rec.target === 'string' && namesDeniedField(rec.target, denied)) return REMOVE; + return scrubRecord(rec, LOOKUP_AUTOFILL_PAIR_POSITIONS, denied); +}; + /** * [ADR-0106 D1] Every `FieldSchema` key, classified. Pinned against the live * schema shape by `object-schema-fls-references.test.ts`. @@ -629,6 +650,9 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { referenceDeletedWhen: deletedWhen, inlineAmountField: pointer, inlineColumns: arrayOf(inlineColumn), + // A lookup's pick autofill pairs (#27945): each `target` is a field of this + // object, each `source` one of the referenced object's. + lookupAutofill: arrayOf(lookupAutofillPair), relatedListColumns: names, dependsOn: nameList(DEPENDS_ON_ENTRY_POSITIONS), // Evaluated against this object's record. diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index edffc002f1d..bdf2983bfeb 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -348,8 +348,10 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // [#26170] `hook` 22 → 23: the declared refusal catalogue, `refusals`. // [#27069] `object` 48 → 49: `relatedLists`, the business order of the // record page's related lists. + // [#27945] `field` 79 → 80: `lookupAutofill`, the fields a pick in a + // lookup fills. const CARD_PROPERTY_COUNTS: Record = { - agent: 24, app: 22, dashboard: 20, dataset: 16, field: 79, flow: 22, + agent: 24, app: 22, dashboard: 20, dataset: 16, field: 80, flow: 22, hook: 23, object: 49, page: 26, position: 13, report: 23, skill: 16, tool: 14, }; diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 08aea3f28d3..f18ee43f27c 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -2007,7 +2007,7 @@ export const FieldSchema = lazySchema(() => { overwrite: z.boolean().optional().describe('TRUE: the pick replaces whatever the target holds, a typed value included. Absent or FALSE: the pick fills the target only while it is empty or still holds the value an earlier pick filled in.'), })).min(1).optional().describe( "Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. " - + "OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. " + + "OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. " + 'Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. ' + "Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy.", ), From 6d9c4c774af9ee5c323971342be7d855152e5bfd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:33:50 +0000 Subject: [PATCH 6/7] chore(spec): regenerate the reference pages for the lookupAutofill describe Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyU9JHYh6atGVnEXr3 --- content/docs/references/data/field.mdx | 2 +- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index bc1b1a908a6..84d1f02024e 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -85,7 +85,7 @@ const result = CurrencyConfigSchema.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | -| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 92e65b6221f..0b35ef1f17e 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -263,7 +263,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | -| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -627,7 +627,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | -| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 6816f1b355f..61d435eeef8 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -97,7 +97,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | -| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -529,7 +529,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | -| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. A pair that declares `overwrite: true` says otherwise out loud and replaces the target on every pick. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | From bda903307dcc7e74b34c33557df74ca3a1cf1891 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:40:41 +0000 Subject: [PATCH 7/7] chore(spec): regenerate the merged references, surface and liveness counts The merge with main took one side of five generated files; regenerate them from the merged tree so both percentDisplay and lookupAutofill are listed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyU9JHYh6atGVnEXr3 --- content/docs/references/data/field.mdx | 1 + content/docs/references/data/object.mdx | 2 ++ content/docs/references/system/migration.mdx | 2 ++ packages/spec/authorable-surface/data.json | 1 + packages/spec/liveness/state-counts/field.md | 2 +- 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 84d1f02024e..542713f20b7 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -61,6 +61,7 @@ const result = CurrencyConfigSchema.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 0b35ef1f17e..48d5c1e3d18 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -239,6 +239,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -603,6 +604,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 61d435eeef8..3628ded946c 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -73,6 +73,7 @@ Add a new field to an existing object | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -505,6 +506,7 @@ Add a new field to an existing object | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 26db0a28b15..be518ea52e0 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -328,6 +328,7 @@ "data/Field:multiple", "data/Field:name", "data/Field:options", + "data/Field:percentDisplay", "data/Field:picklist", "data/Field:placeholder", "data/Field:precision", diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index bef133861bf..e61689f1e07 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 100 | 0 | 0 | 1 | 8 | 109 | +| `field` | 100 | 0 | 0 | 1 | 9 | 110 |