diff --git a/.changeset/30584-blueprint-field-default-value.md b/.changeset/30584-blueprint-field-default-value.md new file mode 100644 index 00000000000..8e7f474ff3f --- /dev/null +++ b/.changeset/30584-blueprint-field-default-value.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): a blueprint field carries a typed `defaultValue`, so an AI-built boolean flag defaults to false + +Clause-②: yes (widening) +Metadata-protocol: added +Skills-docs: none — no file under `skills/**` restates a blueprint field key (`skills/objectstack-ai/references/_index.md` only points at `solution-blueprint.zod.ts`); the reference page `content/docs/references/ai/solution-blueprint.mdx` is regenerated from the new describe. + +The AI solution blueprint had no slot for a field's default. A blueprint that wrote `{ type: 'boolean', defaultValue: false }` for a 是否完成 / 已付款 / `is_active` flag, as the build guidance teaches, parsed with `success: true` and lost the key, so the built field had no default and every record created without the flag read empty instead of false. A filter on false missed those records. + +- **`defaultValue` on `BlueprintFieldSchema` and on the strict structured-output mirror's fields (`SolutionBlueprintStrictSchema.objects[].fields[]`).** Both sides declare it as one scalar union, `boolean | number | string`. Lenient: optional. Strict: required and nullable (null = no default), as with every other key in that mirror. The describe says the value becomes the built field's `defaultValue`, and states the boolean-flag rule: a yes/no flag is a `boolean` field that is never `required` and carries `defaultValue: false`. +- **The value is checked against the field's `type`, on the lenient side.** The check is the field schema's own literal contract (`checkLiteralDefaultValue`, the one `FieldSchema` runs on a built field's `defaultValue`), so a default the blueprint admits is one the built field admits. These types take a default: `boolean` / `toggle` take true or false; `number` / `currency` / `percent` / `rating` / `slider` / `progress` take a number; `text` / `textarea` / `email` / `url` / `phone` / `markdown` / `html` / `richtext` / `code` / `color` take a string; `time` takes `"HH:MM"`; `select` / `radio` take the `value` of one of the field's own `options`, never a label. +- **Every other type is refused by name, never stripped.** The refusal is a `custom` issue at the field's `defaultValue` that names the field and its type, says why, and lists the types that take one: a `date` / `datetime` literal is frozen at design time; a reference or file type names a record or file that does not exist yet; a `multiselect` / `checkboxes` / `tags` field stores a list; a `formula` / `summary` / `autonumber` field is computed; `password` / `secret`, `signature` / `qrcode` and the structured JSON types have no one value right for every record. A runtime token (`NOW()`, `current_user`) is refused too: a blueprint default is a literal. +- The strict mirror cannot read `type` while it judges this key, so it can generate a value the lenient schema then refuses. `apply_blueprint` parses the lenient schema, and that refusal names the type at the key. + +Additive only: every blueprint that parsed before still parses with the lenient schema. A producer of the STRICT shape must now send `defaultValue` on each field (null when it has none). The cloud build applier is what carries the value into the built field; it moves on its own pin. diff --git a/content/docs/references/ai/solution-blueprint.mdx b/content/docs/references/ai/solution-blueprint.mdx index c88aad8d390..6099aa0e00d 100644 --- a/content/docs/references/ai/solution-blueprint.mdx +++ b/content/docs/references/ai/solution-blueprint.mdx @@ -113,6 +113,7 @@ const result = BlueprintAppSchema.parse(data); | **options** | `{ label: string; value: string; kind?: Enum<'open' \| 'won' \| 'lost'> }[]` | optional | Choices for select / multiselect / radio fields | | **summaryOperations** | `{ object: string; function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>; field?: string; relationshipField?: string; … }` | optional | REQUIRED when `type` is "summary" (a roll-up of child records: 任务总数 / 报名人数 / 合计金额 / 已完成任务数). Names the child object, the aggregation, and — for a qualified count/sum — the condition. A "summary" field without it materializes runtime-dead. | | **expression** | `string` | optional | REQUIRED when `type` is "formula" — the CEL body the field computes, e.g. "record.quantity * record.unit_price", or "record.order_no + ' · ' + record.customer.name" for a composed title. A formula reads a lookup / master_detail field's related record ONE hop through it — `record..`, as `record.customer.name` above; the bare `record.customer` is the stored id, and a second hop (`record.customer.owner.name`) is refused at build. A "formula" field without it materializes runtime-dead: the engine builds its formula plan only from fields that HAVE an expression, so the field reads null everywhere, forever. Same failure shape as a "summary" with no `summaryOperations`. Note `nameField` on the object recommends a formula for numbered entities (invoice/ticket) — that formula needs THIS key, or the record title is blank on every card, lookup chip and breadcrumb. | +| **defaultValue** | `boolean \| number \| string` | optional | The built field's default: the value a new record gets when it is created without one, carried into the object field as its `defaultValue` and stored as written. It must fit `type`, and only these types take one: "boolean" / "toggle" → true or false; "number" / "currency" / "percent" / "rating" / "slider" / "progress" → a number; "text" / "textarea" / "email" / "url" / "phone" / "markdown" / "html" / "richtext" / "code" / "color" → a string; "time" → a time of day, "HH:MM"; "select" / "radio" → the `value` of one of the field's own `options`, never its label. Every other type is refused by name: a date is fixed at design time, a reference or file names no record yet, a multi-value field stores a list, a formula / summary / autonumber is computed, and a credential, signature or structured value has no one value right for every record. A yes/no flag (是否完成 / 已付款 / is_active) is a "boolean" field that is never `required` (omit `required` or set it false) and carries defaultValue false: a required flag refuses the unchecked switch, and a flag with no default is empty, not false, on every record created without it, so a filter on false misses those records. Omit it for a field with no default. | ### Allowed Values: `BlueprintField.type` @@ -229,6 +230,7 @@ const result = BlueprintAppSchema.parse(data); | **options** | `{ label: string; value: string; kind?: Enum<'open' \| 'won' \| 'lost'> }[]` | optional | Choices for select / multiselect / radio fields | | **summaryOperations** | `{ object: string; function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>; field?: string; relationshipField?: string; … }` | optional | REQUIRED when `type` is "summary" (a roll-up of child records: 任务总数 / 报名人数 / 合计金额 / 已完成任务数). Names the child object, the aggregation, and — for a qualified count/sum — the condition. A "summary" field without it materializes runtime-dead. | | **expression** | `string` | optional | REQUIRED when `type` is "formula" — the CEL body the field computes, e.g. "record.quantity * record.unit_price", or "record.order_no + ' · ' + record.customer.name" for a composed title. A formula reads a lookup / master_detail field's related record ONE hop through it — `record..`, as `record.customer.name` above; the bare `record.customer` is the stored id, and a second hop (`record.customer.owner.name`) is refused at build. A "formula" field without it materializes runtime-dead: the engine builds its formula plan only from fields that HAVE an expression, so the field reads null everywhere, forever. Same failure shape as a "summary" with no `summaryOperations`. Note `nameField` on the object recommends a formula for numbered entities (invoice/ticket) — that formula needs THIS key, or the record title is blank on every card, lookup chip and breadcrumb. | +| **defaultValue** | `boolean \| number \| string` | optional | The built field's default: the value a new record gets when it is created without one, carried into the object field as its `defaultValue` and stored as written. It must fit `type`, and only these types take one: "boolean" / "toggle" → true or false; "number" / "currency" / "percent" / "rating" / "slider" / "progress" → a number; "text" / "textarea" / "email" / "url" / "phone" / "markdown" / "html" / "richtext" / "code" / "color" → a string; "time" → a time of day, "HH:MM"; "select" / "radio" → the `value` of one of the field's own `options`, never its label. Every other type is refused by name: a date is fixed at design time, a reference or file names no record yet, a multi-value field stores a list, a formula / summary / autonumber is computed, and a credential, signature or structured value has no one value right for every record. A yes/no flag (是否完成 / 已付款 / is_active) is a "boolean" field that is never `required` (omit `required` or set it false) and carries defaultValue false: a required flag refuses the unchecked switch, and a flag with no default is empty, not false, on every record created without it, so a filter on false misses those records. Omit it for a field with no default. | --- diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json index dc676d2c3fa..4b760c1de0c 100644 --- a/packages/spec/authorable-surface/ai.json +++ b/packages/spec/authorable-surface/ai.json @@ -46,6 +46,7 @@ "ai/BlueprintDashboard:label", "ai/BlueprintDashboard:name", "ai/BlueprintDashboard:widgets", + "ai/BlueprintField:defaultValue", "ai/BlueprintField:expression", "ai/BlueprintField:label", "ai/BlueprintField:name", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 202f948a5e5..27a14925df8 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 252, - "droppedRefinementSites": 1020, + "droppedRefinementSites": 1023, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -16,11 +16,13 @@ }, "ai/BlueprintField": { "sites": [ + "", "summaryOperations.filter.lazy" ] }, "ai/BlueprintObject": { "sites": [ + "fields.element", "fields.element.summaryOperations.filter.lazy" ] }, @@ -46,6 +48,7 @@ }, "ai/SolutionBlueprint": { "sites": [ + "objects.element.fields.element", "objects.element.fields.element.summaryOperations.filter.lazy", "views.element.filters.element" ] diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index 1dd7b6e4051..b9be4985fe7 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -10,7 +10,7 @@ import { } from './solution-blueprint.zod'; import { ViewFilterRuleSchema } from '../ui/view.zod'; import { SortItemSchema } from '../shared/enums.zod'; -import { SelectOptionSchema } from '../data/field.zod'; +import { FieldSchema, FieldType, SelectOptionSchema } from '../data/field.zod'; import { ObjectSchema } from '../data/object.zod'; const validBlueprint: SolutionBlueprint = { @@ -481,7 +481,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { nameField: null, stageField: null, fields: [ - { name: 'name', label: null, type: 'text', required: null, reference: null, options: null, summaryOperations: null, expression: null }, + { name: 'name', label: null, type: 'text', required: null, reference: null, options: null, summaryOperations: null, expression: null, defaultValue: null }, ], }, ], @@ -520,7 +520,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { const badField = { ...strictBp, objects: [ - { name: 'x', label: null, description: null, sharingModel: null, nameField: null, stageField: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null, summaryOperations: null, expression: null }] }, + { name: 'x', label: null, description: null, sharingModel: null, nameField: null, stageField: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null, summaryOperations: null, expression: null, defaultValue: null }] }, ], }; // `f` is missing the (nullable, required) `label` key. @@ -546,11 +546,11 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { stageField: null, fields: [ { - name: 'task_total', label: '任务总数', type: 'summary', required: null, reference: null, options: null, expression: null, + name: 'task_total', label: '任务总数', type: 'summary', required: null, reference: null, options: null, expression: null, defaultValue: null, summaryOperations: { object: 'task', function: 'count', field: 'id', relationshipField: null, conditions: null }, }, { - name: 'completed_task_count', label: '已完成任务数', type: 'summary', required: null, reference: null, options: null, expression: null, + name: 'completed_task_count', label: '已完成任务数', type: 'summary', required: null, reference: null, options: null, expression: null, defaultValue: null, summaryOperations: { object: 'task', function: 'count', field: 'id', relationshipField: null, conditions: [{ field: 'status', op: 'eq', value: 'completed' }], @@ -622,7 +622,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { describe('stageField + option kind (the model names the stage field and states each option\'s kind, or says null)', () => { const statusField = (options: Array>) => ({ - name: 'status', label: null, type: 'select', required: null, reference: null, options, summaryOperations: null, expression: null, + name: 'status', label: null, type: 'select', required: null, reference: null, options, summaryOperations: null, expression: null, defaultValue: null, }); const STAGES = [ { label: 'New', value: 'new', kind: 'open' }, @@ -1153,6 +1153,7 @@ describe('strict mirror ↔ lenient schema — VALUE parity', () => { options: optionsWithLeadingDigit.map((o) => ({ ...o, kind: null })), summaryOperations: null, expression: null, + defaultValue: null, }], }], views: null, @@ -1180,7 +1181,7 @@ describe('blueprint widget `aggregate` and calendar view `startDateField`', () = name: 'interview', label: null, description: null, sharingModel: null, nameField: null, stageField: null, fields: [{ name: 'score', label: null, type: 'number', required: null, reference: null, options: null, - summaryOperations: null, expression: null, + summaryOperations: null, expression: null, defaultValue: null, }], }], views: null, @@ -1295,3 +1296,221 @@ describe('blueprint widget `aggregate` and calendar view `startDateField`', () = } }); }); + +// --------------------------------------------------------------------------- +// Field `defaultValue` (ruling B on cloud#3619). Optional on the lenient side +// and required-but-nullable on the strict mirror; one scalar union on both. +// Whether a field takes a default, and which value, is the lenient field's +// refinement: the mirror cannot read `type` while it judges this key. +// --------------------------------------------------------------------------- +describe('blueprint field `defaultValue`', () => { + /** + * The design call this PR states, restated here so a widening of the admitted + * set is a diff to this pin: each admitted type with a value it takes, and a + * value of the wrong type for it. + */ + const ADMITTED: Record = { + boolean: { ok: false, wrong: 'false' }, + toggle: { ok: true, wrong: 1 }, + number: { ok: 0, wrong: '0' }, + currency: { ok: 99.5, wrong: '99.5' }, + percent: { ok: 50, wrong: true }, + rating: { ok: 3, wrong: '3' }, + slider: { ok: 10, wrong: '10' }, + progress: { ok: 0, wrong: false }, + text: { ok: '待处理', wrong: 0 }, + textarea: { ok: '', wrong: false }, + email: { ok: 'ops@example.com', wrong: 1 }, + url: { ok: 'https://example.com', wrong: 1 }, + phone: { ok: '+1 555 0100', wrong: 5550100 }, + markdown: { ok: '# Notes', wrong: 1 }, + html: { ok: '

Notes

', wrong: 1 }, + richtext: { ok: 'Notes', wrong: 1 }, + code: { ok: '{}', wrong: 1 }, + color: { ok: '#3b82f6', wrong: 1 }, + time: { ok: '09:00', wrong: '9am' }, + select: { ok: 'open', wrong: 'Open' }, + radio: { ok: 'done', wrong: 'closed' }, + }; + const OPTIONS = [{ label: 'Open', value: 'open' }, { label: 'Done', value: 'done' }]; + const withOptions = (type: string) => (type === 'select' || type === 'radio' ? { options: OPTIONS } : {}); + const lenientField = (field: Record) => SolutionBlueprintSchema.safeParse({ + summary: 's', + objects: [{ name: 'task', fields: [{ name: 'flag', ...field }] }], + }); + const strictField = (field: Record) => SolutionBlueprintStrictSchema.safeParse({ + summary: 's', + assumptions: [], + questions: null, + objects: [{ + name: 'task', label: null, description: null, sharingModel: null, nameField: null, stageField: null, + fields: [{ + name: 'flag', label: null, required: null, reference: null, options: null, summaryOperations: null, + expression: null, defaultValue: null, ...field, + }], + }], + views: null, + dashboards: null, + app: null, + }); + const PATH = ['objects', 0, 'fields', 0, 'defaultValue']; + /** The by-name refusals of a failed parse: custom issues at the key whose message names the field type. */ + const namedRefusals = (result: ReturnType, type: string) => + (result.error?.issues ?? []).filter((i) => i.code === 'custom' + && JSON.stringify(i.path) === JSON.stringify(PATH) + && i.message.includes(`"flag" (${type})`)); + + it('carries a boolean flag\'s `defaultValue: false` through the parse, and the key survives', () => { + const parsed = lenientField({ type: 'boolean', defaultValue: false }); + expect(parsed.success).toBe(true); + const field = parsed.data!.objects[0].fields[0]; + expect(Object.prototype.hasOwnProperty.call(field, 'defaultValue')).toBe(true); + expect(field.defaultValue).toBe(false); + }); + + it('admits exactly the declared types: each takes its value, every other FieldType is refused by name', () => { + expect(Object.keys(ADMITTED).every((t) => (FieldType.options as readonly string[]).includes(t))).toBe(true); + for (const type of FieldType.options) { + const admitted = ADMITTED[type]; + // A value each refused type's own value class would hold, so the refusal is + // the type's, never the value's. + const value = admitted ? admitted.ok : (type === 'date' ? '2026-01-01' : type === 'number' ? 0 : 'x'); + const result = lenientField({ type, ...withOptions(type), defaultValue: value }); + if (admitted) { + expect({ type, success: result.success }).toEqual({ type, success: true }); + expect(result.data!.objects[0].fields[0].defaultValue).toEqual(value); + } else { + expect({ type, success: result.success }).toEqual({ type, success: false }); + expect({ type, named: namedRefusals(result, type).length }).toEqual({ type, named: 1 }); + } + } + }); + + it('refuses a value of the wrong type for an admitted type, by name at the key', () => { + for (const [type, { wrong }] of Object.entries(ADMITTED)) { + const result = lenientField({ type, ...withOptions(type), defaultValue: wrong }); + expect({ type, success: result.success }).toEqual({ type, success: false }); + expect({ type, issues: result.error!.issues.length, named: namedRefusals(result, type).length }) + .toEqual({ type, issues: 1, named: 1 }); + } + }); + + it('refuses a select default that is not one of the field\'s OWN option values', () => { + for (const bad of ['Open', 'closed', 'done ']) { + expect(namedRefusals(lenientField({ type: 'select', options: OPTIONS, defaultValue: bad }), 'select')).toHaveLength(1); + } + // A value another field declares is not this field's option. + const other = SolutionBlueprintSchema.safeParse({ + summary: 's', + objects: [{ + name: 'task', + fields: [ + { name: 'priority', type: 'select', options: [{ label: 'High', value: 'high' }] }, + { name: 'flag', type: 'select', options: OPTIONS, defaultValue: 'high' }, + ], + }], + }); + expect(other.error?.issues.map((i) => i.path)).toEqual([['objects', 0, 'fields', 1, 'defaultValue']]); + // With no options declared there is nothing a default could name. + expect(namedRefusals(lenientField({ type: 'radio', defaultValue: 'open' }), 'radio')).toHaveLength(1); + }); + + it('refuses a runtime token on an admitted type: a blueprint default is a literal', () => { + for (const [type, token] of [['text', 'NOW()'], ['time', 'now()'], ['select', 'current_user']] as const) { + const options = type === 'select' ? { options: [...OPTIONS, { label: 'Me', value: 'current_user' }] } : {}; + expect(namedRefusals(lenientField({ type, ...options, defaultValue: token }), type)).toHaveLength(1); + } + }); + + it('refuses a runtime token on a type that takes no default with the token\'s reason, never the type\'s', () => { + // The type's reason is false of a token: `current_user` names no record and + // `NOW()` is not frozen at design time. + for (const [type, token] of [['user', 'current_user'], ['lookup', 'current_user'], ['date', 'NOW()'], ['datetime', 'now()']] as const) { + const result = lenientField({ type, ...(type === 'lookup' ? { reference: 'sys_user' } : {}), defaultValue: token }); + const named = namedRefusals(result, type); + expect({ type, issues: result.error?.issues.length, named: named.length }).toEqual({ type, issues: 1, named: 1 }); + expect(named[0].message).toContain('is a runtime token'); + expect(named[0].message).toContain(`A "${type}" field takes no defaultValue in a blueprint`); + expect(named[0].message.endsWith('Remove defaultValue from this field.')).toBe(true); + expect(named[0].message).not.toMatch(/no record exists|fixed when the blueprint is written/); + } + }); + + it('refuses a list default on a multi-value field by name, beside the key\'s own union refusal', () => { + const result = lenientField({ type: 'multiselect', options: OPTIONS, defaultValue: ['open'] }); + expect(result.success).toBe(false); + expect(namedRefusals(result, 'multiselect')).toHaveLength(1); + }); + + it('absent still parses on the lenient side; the strict mirror takes null and refuses the key missing', () => { + const lenient = lenientField({ type: 'boolean' }); + expect(lenient.success).toBe(true); + expect('defaultValue' in lenient.data!.objects[0].fields[0]).toBe(false); + expect(strictField({ type: 'boolean', defaultValue: null }).success).toBe(true); + // Strict mode needs every key in `required`: a field without the key is refused at it. + const bare = SolutionBlueprintStrictSchema.safeParse({ + summary: 's', assumptions: [], questions: null, views: null, dashboards: null, app: null, + objects: [{ + name: 'task', label: null, description: null, sharingModel: null, nameField: null, stageField: null, + fields: [{ + name: 'flag', label: null, type: 'boolean', required: null, reference: null, options: null, + summaryOperations: null, expression: null, + }], + }], + }); + expect(bare.error?.issues.map((i) => i.path)).toEqual([PATH]); + }); + + it('the strict mirror accepts what the lenient schema accepts for each admitted type, and null', () => { + for (const [type, { ok }] of Object.entries(ADMITTED)) { + const options = type === 'select' || type === 'radio' ? { options: OPTIONS.map((o) => ({ ...o, kind: null })) } : {}; + const strict = strictField({ type, ...options, defaultValue: ok }); + expect({ type, success: strict.success }).toEqual({ type, success: true }); + expect(strict.data!.objects[0].fields[0].defaultValue).toEqual(ok); + expect({ type, success: lenientField({ type, ...withOptions(type), defaultValue: ok }).success }) + .toEqual({ type, success: true }); + expect(strictField({ type, ...options, defaultValue: null }).success).toBe(true); + } + }); + + it('a value legal to generate is either kept by the lenient schema or refused there BY NAME, never stripped', () => { + // VALUE parity for a key whose rule spans `type`: the mirror's union cannot + // say which arm a type takes, so it generates values the lenient field + // refuses. Every such refusal must name the field's type at this key. + for (const type of FieldType.options) { + for (const value of [true, false, 0, 7.5, '', 'x', 'open', '09:00', '2026-01-01']) { + expect({ type, value, strict: strictField({ type, defaultValue: value }).success }) + .toEqual({ type, value, strict: true }); + const lenient = lenientField({ type, ...withOptions(type), defaultValue: value }); + if (lenient.success) { + expect({ type, value, kept: lenient.data!.objects[0].fields[0].defaultValue }) + .toEqual({ type, value, kept: value }); + } else { + expect({ type, value, issues: lenient.error!.issues.length, named: namedRefusals(lenient, type).length }) + .toEqual({ type, value, issues: 1, named: 1 }); + } + } + } + }); + + it('every default the blueprint admits is one the built field\'s own schema admits', () => { + // The applier carries the value into the object field, where `FieldSchema` + // judges it again: a value legal to apply must be legal to stage. + for (const [type, { ok }] of Object.entries(ADMITTED)) { + const built = FieldSchema.safeParse({ name: 'flag', label: 'Flag', type, ...withOptions(type), defaultValue: ok }); + expect({ type, success: built.success }).toEqual({ type, success: true }); + expect(built.data?.defaultValue).toEqual(ok); + } + }); + + it('both describes state the admitted types and the boolean-flag rule', () => { + const describeOf = (root: any): string => + root.shape.objects.element.shape.fields.element.shape.defaultValue.description; + for (const text of [describeOf(SolutionBlueprintSchema), describeOf(SolutionBlueprintStrictSchema)]) { + for (const type of Object.keys(ADMITTED)) expect(text).toContain(`"${type}"`); + expect(text).toContain('never `required`'); + expect(text).toContain('defaultValue false'); + expect(text).toContain('refused by name'); + } + }); +}); diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index abb11f3c5e6..b490c5707cb 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { FieldType, SelectOptionSchema } from '../data/field.zod'; +import { checkLiteralDefaultValue } from '../data/default-value-shape'; +import { isRuntimeDefaultToken } from '../data/default-value-tokens'; import { FilterConditionSchema } from '../data/filter.zod'; import { SortDirectionEnum, SortItemSchema } from '../shared/enums.zod'; import { VIEW_FILTER_OPERATORS, ViewFilterRuleSchema } from '../ui/view.zod'; @@ -101,6 +103,178 @@ export const BlueprintSummaryOperationsSchema = lazySchema(() => z.object({ })); export type BlueprintSummaryOperations = z.input; +/** What a blueprint field of one type may say in `defaultValue` (see {@link BLUEPRINT_DEFAULT_VALUE_RULES}). */ +type BlueprintDefaultValueRule = + /** Admitted: `takes` names the value, `fromOptions` marks a default that must be one of the field's own options. */ + | { readonly takes: string; readonly fromOptions?: true } + /** Refused by name: `because` is the reason the refusal states. */ + | { readonly because: string }; + +/** + * Which field types a blueprint field may give a `defaultValue`, and what each + * one takes (ruling B on cloud#3619). One row per `FieldType` member, so a type + * added to the field vocabulary does not compile here until someone decides + * whether a blueprint may default it. + * + * Admitted: the types whose default a design can state as ONE literal that the + * built field stores as written. An admitted value is judged by the field + * schema's own literal contract (`checkLiteralDefaultValue`, ADR-0104 D1, the + * check `FieldSchema` runs on the built field's `defaultValue`), so a value + * legal here is legal on the field the applier writes. Every admitted value is a + * boolean, a number or a string, which is why the strict mirror can carry the + * key as one scalar union. + * + * Refused, by name, even where `FieldSchema` would take a default: + * - `date` / `datetime`: a literal day is frozen when the blueprint is written. + * "Today" is the runtime token `NOW()`, an instruction the built field + * resolves, not a value; a blueprint default is a literal only. + * - the reference and file types: the value is the id of a record or an + * uploaded file, and none exists while the blueprint is designed (seed data + * is reported, never applied). `current_user` is a runtime token too. + * - `multiselect` / `checkboxes` / `tags`: the stored value is an array, and + * this key carries one scalar. + * - `formula` / `summary` / `autonumber`: computed, so a default is never read + * (`summary` is numeric in the value classes, and still refused). + * - `password` / `secret`, `signature` / `qrcode`, and the structured JSON + * types: no one value is right for every record, or the value is not a + * scalar at all. + */ +const BLUEPRINT_DEFAULT_VALUE_RULES: Readonly> = { + boolean: { takes: 'true or false' }, + toggle: { takes: 'true or false' }, + number: { takes: 'a number' }, + currency: { takes: 'a number' }, + percent: { takes: 'a number' }, + rating: { takes: 'a number' }, + slider: { takes: 'a number' }, + progress: { takes: 'a number' }, + text: { takes: 'a string' }, + textarea: { takes: 'a string' }, + email: { takes: 'a string' }, + url: { takes: 'a string' }, + phone: { takes: 'a string' }, + markdown: { takes: 'a string' }, + html: { takes: 'a string' }, + richtext: { takes: 'a string' }, + code: { takes: 'a string' }, + color: { takes: 'a string' }, + time: { takes: 'a time of day, "HH:MM"' }, + select: { takes: 'the `value` of one of the field\'s own `options`, never its label', fromOptions: true }, + radio: { takes: 'the `value` of one of the field\'s own `options`, never its label', fromOptions: true }, + date: { because: 'a literal date is fixed when the blueprint is written, so every record created later would start on that same day' }, + datetime: { because: 'a literal date is fixed when the blueprint is written, so every record created later would start on that same day' }, + lookup: { because: 'its value is the id of a record, and no record exists while the blueprint is designed' }, + master_detail: { because: 'its value is the id of a record, and no record exists while the blueprint is designed' }, + tree: { because: 'its value is the id of a record, and no record exists while the blueprint is designed' }, + user: { because: 'its value is the id of a user record, and no record exists while the blueprint is designed' }, + image: { because: 'its value is the id of an uploaded file, and no file exists while the blueprint is designed' }, + file: { because: 'its value is the id of an uploaded file, and no file exists while the blueprint is designed' }, + avatar: { because: 'its value is the id of an uploaded file, and no file exists while the blueprint is designed' }, + video: { because: 'its value is the id of an uploaded file, and no file exists while the blueprint is designed' }, + audio: { because: 'its value is the id of an uploaded file, and no file exists while the blueprint is designed' }, + multiselect: { because: 'it stores a list of values, and a blueprint default is one value' }, + checkboxes: { because: 'it stores a list of values, and a blueprint default is one value' }, + tags: { because: 'it stores a list of values, and a blueprint default is one value' }, + formula: { because: 'the platform computes its value, so a default is never read' }, + summary: { because: 'the platform computes its value, so a default is never read' }, + autonumber: { because: 'the platform computes its value, so a default is never read' }, + password: { because: 'a default would give every record the same credential' }, + secret: { because: 'a default would give every record the same credential' }, + signature: { because: 'its value is made for each record, so one default for every record is never right' }, + qrcode: { because: 'its value is made for each record, so one default for every record is never right' }, + json: { because: 'its value is structured data, and a blueprint default is one plain value' }, + composite: { because: 'its value is structured data, and a blueprint default is one plain value' }, + repeater: { because: 'its value is structured data, and a blueprint default is one plain value' }, + record: { because: 'its value is structured data, and a blueprint default is one plain value' }, + location: { because: 'its value is structured data, and a blueprint default is one plain value' }, + address: { because: 'its value is structured data, and a blueprint default is one plain value' }, + vector: { because: 'its value is structured data, and a blueprint default is one plain value' }, +}; + +/** + * The admitted types and what each takes, as one sentence: the rows of + * {@link BLUEPRINT_DEFAULT_VALUE_RULES} that admit a default, grouped by the + * value they take, in table order. Read by both describes and by the refusal. + */ +const BLUEPRINT_DEFAULT_VALUE_ADMITTED = (() => { + const groups = new Map(); + for (const [type, rule] of Object.entries(BLUEPRINT_DEFAULT_VALUE_RULES) as Array<[string, BlueprintDefaultValueRule]>) { + if (!('takes' in rule)) continue; + groups.set(rule.takes, [...(groups.get(rule.takes) ?? []), type]); + } + return [...groups].map(([takes, types]) => `${types.map((t) => `"${t}"`).join(' / ')} → ${takes}`).join('; '); +})(); + +/** Which types take a default and what each takes, read by both describes. */ +const DEFAULT_VALUE_TYPE_RULE = `It must fit \`type\`, and only these types take one: ${BLUEPRINT_DEFAULT_VALUE_ADMITTED}. ` + + 'Every other type is refused by name: a date is fixed at design time, a reference or file names no record yet, a ' + + 'multi-value field stores a list, a formula / summary / autonumber is computed, and a credential, signature or ' + + 'structured value has no one value right for every record.'; + +/** + * The boolean-flag rule both describes state (cloud#972 guidance, ruling B on + * cloud#3619), with each side's spelling of an unset `required`. + */ +const booleanFlagRule = (unsetRequired: string) => + 'A yes/no flag (是否完成 / 已付款 / is_active) is a "boolean" field that is never `required` ' + + `(${unsetRequired}) and carries defaultValue false: a required flag refuses the unchecked switch, and a flag with no ` + + 'default is empty, not false, on every record created without it, so a filter on false misses those records.'; + +/** + * The refusal of a `defaultValue` that does not fit the field (ruling B on + * cloud#3619): a runtime token, a type that takes none, a select / radio that + * declares no options, or a value outside the type's literal contract. Each + * issue lands on the field's `defaultValue` and names the field and its type. + * + * It runs on every parse (`when: () => true`): the key's own scalar union + * aborts on an array or object, and a refusal that named nothing would leave a + * `multiselect` default refused without saying why. Every read below is + * guarded for that reason, and a `type` outside `FieldType` is left to that + * key's own issue. + */ +function refuseUnfitDefaultValue(field: unknown, ctx: z.RefinementCtx): void { + if (typeof field !== 'object' || field === null) return; + const { name, type, options, defaultValue } = field as Record; + // Absence. The strict mirror's null is stripped before this schema reads it, + // and a lenient null is refused by the key's own union. + if (defaultValue === undefined || defaultValue === null) return; + if (typeof type !== 'string' || !Object.prototype.hasOwnProperty.call(BLUEPRINT_DEFAULT_VALUE_RULES, type)) return; + const rule = BLUEPRINT_DEFAULT_VALUE_RULES[type as FieldType]; + const subject = `Field "${typeof name === 'string' ? name : ''}" (${type})`; + const shown = JSON.stringify(defaultValue) ?? String(defaultValue); + const refuse = (message: string) => ctx.addIssue({ code: 'custom', path: ['defaultValue'], message }); + + // A token is judged before the type. On a type that takes no default, that + // type's reason is false of a token (`current_user` names no record, `NOW()` + // is not frozen), and an author steered by it writes a record id or a fixed day. + if (isRuntimeDefaultToken(defaultValue)) { + const instead = 'takes' in rule + ? `A "${type}" field's default is ${rule.takes}; write one, or remove defaultValue.` + : `A "${type}" field takes no defaultValue in a blueprint (the types that take one: ` + + `${BLUEPRINT_DEFAULT_VALUE_ADMITTED}). Remove defaultValue from this field.`; + refuse(`${subject}: the defaultValue ${shown} is a runtime token, resolved when a record is created rather than ` + + `stored, and a blueprint default is a literal value the built field stores as written. ${instead}`); + return; + } + if (!('takes' in rule)) { + refuse(`${subject}: a "${type}" field takes no defaultValue in a blueprint, because ${rule.because}. The types that ` + + `take one: ${BLUEPRINT_DEFAULT_VALUE_ADMITTED}. Remove defaultValue from this field.`); + return; + } + const declared = Array.isArray(options) ? options as Array<{ value: string }> : undefined; + if (rule.fromOptions && !declared?.length) { + refuse(`${subject}: the defaultValue ${shown} must be the \`value\` of one of the field's own \`options\`, and the ` + + 'field declares none. Declare the options, or remove defaultValue.'); + return; + } + const verdict = checkLiteralDefaultValue({ type, options: declared }, defaultValue); + if (!verdict.ok) { + refuse(`${subject}: the defaultValue ${shown} does not fit the field's type (${verdict.detail ?? 'invalid value'}). ` + + `A "${type}" field's default is ${rule.takes}, stored as written on every record created without one; write ` + + 'one, or remove defaultValue.'); + } +} + /** * A proposed field on a blueprint object. `reference` carries the target * object for `lookup` / `master_detail` types — relationships are expressed @@ -123,7 +297,12 @@ export const BlueprintFieldSchema = lazySchema(() => z.object({ .describe('REQUIRED when `type` is "summary" (a roll-up of child records: 任务总数 / 报名人数 / 合计金额 / 已完成任务数). Names the child object, the aggregation, and — for a qualified count/sum — the condition. A "summary" field without it materializes runtime-dead.'), expression: z.string().optional() .describe('REQUIRED when `type` is "formula" — the CEL body the field computes, e.g. "record.quantity * record.unit_price", or "record.order_no + \' · \' + record.customer.name" for a composed title. A formula reads a lookup / master_detail field\'s related record ONE hop through it — `record..`, as `record.customer.name` above; the bare `record.customer` is the stored id, and a second hop (`record.customer.owner.name`) is refused at build. A "formula" field without it materializes runtime-dead: the engine builds its formula plan only from fields that HAVE an expression, so the field reads null everywhere, forever. Same failure shape as a "summary" with no `summaryOperations`. Note `nameField` on the object recommends a formula for numbered entities (invoice/ticket) — that formula needs THIS key, or the record title is blank on every card, lookup chip and breadcrumb.'), -})); + // Ruling B on cloud#3619. The scalar union is the value space every admitted + // type draws from; whether THIS field takes a default, and which value, is a + // rule across `type` / `options` and this key, so it is the refinement below. + defaultValue: z.union([z.boolean(), z.number(), z.string()]).optional() + .describe(`The built field's default: the value a new record gets when it is created without one, carried into the object field as its \`defaultValue\` and stored as written. ${DEFAULT_VALUE_TYPE_RULE} ${booleanFlagRule('omit `required` or set it false')} Omit it for a field with no default.`), +}).superRefine(refuseUnfitDefaultValue, { when: () => true })); export type BlueprintField = z.input; /** A proposed business object (table) with its fields. */ @@ -425,6 +604,16 @@ const StrictField = z.object({ .describe('REQUIRED when type is "summary" (a roll-up of child records onto this parent: 任务总数 / 报名人数 / 合计金额 / 已完成任务数); null for every other field type. A "summary" field without it is runtime-dead — it reads 0/empty everywhere.'), expression: z.string().nullable() .describe('REQUIRED when type is "formula" — the CEL body the field computes, e.g. "record.quantity * record.unit_price", or "record.order_no + \' · \' + record.customer.name" for a composed record title; null for every other field type. A formula reads a lookup / master_detail field\'s related record ONE hop through it — `record..`, as `record.customer.name` above; the bare `record.customer` is the stored id, and a second hop (`record.customer.owner.name`) is refused at build. A "formula" field without it is runtime-dead — it reads null everywhere, forever. This is the formula analogue of summaryOperations: name the type and you must supply the body.'), + // ⛔ Must stay in lockstep with the lenient `BlueprintFieldSchema.defaultValue` + // (ruling B on cloud#3619). One scalar union, because strict structured + // output states a rule per key and cannot read `type` while judging this one: + // which of the three a field takes is the lenient field's refinement, run when + // `apply_blueprint` parses. So a value legal to generate here can still be + // refused there, and it is then refused BY NAME at this key (the VALUE-parity + // pin walks every type × arm), never stripped. The field key-parity pin fails + // if either side drops the key. + defaultValue: z.union([z.boolean(), z.number(), z.string()]).nullable() + .describe(`The built field's default, or null for none: the value a new record gets when it is created without one, carried into the object field as its \`defaultValue\` and stored as written. ${DEFAULT_VALUE_TYPE_RULE} Null on every other type. ${booleanFlagRule('`required` null or false')}`), }); const StrictObject = z.object({