From f010017d934babff15db3bdb5e7666b9efca73f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 20:22:40 +0000 Subject: [PATCH 1/4] feat(spec): a blueprint field carries a typed `defaultValue`, so an AI-built boolean flag defaults to false `BlueprintFieldSchema` and the strict structured-output mirror `StrictField` gain `defaultValue`: a boolean / number / string union, optional on the lenient side and required-but-nullable on the mirror. The lenient field checks the value against its `type` with the field schema's own literal contract (`checkLiteralDefaultValue`): 21 types take a default, the other 28 are refused by name at the key, and a select / radio default must be one of the field's own option values. Claude-Session: https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2 Co-authored-by: Claude --- .../spec/src/ai/solution-blueprint.test.ts | 219 +++++++++++++++++- .../spec/src/ai/solution-blueprint.zod.ts | 185 ++++++++++++++- 2 files changed, 396 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index 1dd7b6e4051..5610a867a36 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,207 @@ 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 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..16639542ea5 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,172 @@ 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 type that takes none, a runtime token, 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 }); + + 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; + } + if (isRuntimeDefaultToken(defaultValue)) { + 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. A "${type}" field's ` + + `default is ${rule.takes}; write one, or remove defaultValue.`); + 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 +291,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 +598,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({ From 5d57ab6a839fe11e0a1978717f4c67ca9c6060bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 20:28:35 +0000 Subject: [PATCH 2/4] chore(spec): regenerate the blueprint reference page and record the field's dropped refinement The `defaultValue` type check is a lenient-side refinement with no JSON Schema form (a select default must be one of the field's own option values), so the three published schemas that reach the blueprint field record its site in `dropped-refinements.baseline.json`. Adds the `@objectstack/spec` minor changeset. Claude-Session: https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2 Co-authored-by: Claude --- .../30584-blueprint-field-default-value.md | 18 ++++++++++++++++++ .../docs/references/ai/solution-blueprint.mdx | 2 ++ packages/spec/authorable-surface/ai.json | 1 + .../spec/dropped-refinements.baseline.json | 3 +++ 4 files changed, 24 insertions(+) create mode 100644 .changeset/30584-blueprint-field-default-value.md 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..0d3d648ff57 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -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" ] From 13a078c8a8cc60ad7f2fbd605468f1ada453234d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 20:31:54 +0000 Subject: [PATCH 3/4] chore(spec): the dropped-refinements ledger header counts the three new sites Claude-Session: https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2 Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 0d3d648ff57..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 }, From 80d143dc7e4856f2f3bb345efb861c2398849570 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:34:10 +0000 Subject: [PATCH 4/4] fix(spec): a runtime token on a blueprint field is refused as a token before its type is judged A `user` field with `defaultValue: 'current_user'` or a `date` field with `defaultValue: 'NOW()'` was refused with that type's own reason (no record exists yet / a literal day is frozen), which is false of a token. The token check now runs first for every type, and on a type that takes no default the refusal says it is a runtime token, that the type takes no defaultValue in a blueprint, and to remove it. Claude-Session: https://claude.ai/code/session_01L6bhgUCEUicLCQ4tkXayK2 Co-authored-by: Claude --- .../spec/src/ai/solution-blueprint.test.ts | 14 +++++++++++++ .../spec/src/ai/solution-blueprint.zod.ts | 20 ++++++++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index 5610a867a36..b9be4985fe7 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -1422,6 +1422,20 @@ describe('blueprint field `defaultValue`', () => { } }); + 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); diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index 16639542ea5..b490c5707cb 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -222,7 +222,7 @@ const booleanFlagRule = (unsetRequired: string) => /** * The refusal of a `defaultValue` that does not fit the field (ruling B on - * cloud#3619): a type that takes none, a runtime token, a select / radio that + * 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. * @@ -244,17 +244,23 @@ function refuseUnfitDefaultValue(field: unknown, ctx: z.RefinementCtx): void { 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; } - if (isRuntimeDefaultToken(defaultValue)) { - 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. A "${type}" field's ` - + `default is ${rule.takes}; write one, or remove defaultValue.`); - 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 `