From 6be5487d57409ddc45213b4021e4f285aeab35ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:22:16 +0000 Subject: [PATCH 1/2] feat(spec/ai): blueprint select options declare their lifecycle kind and a blueprint object names its stageField Both option shapes (lenient BlueprintFieldSchema.options[] and the strict mirror) gain kind, taken by reference from SelectOption.kind's enum; both object shapes gain stageField. The describes state what the object schema refuses about the field stageField names. Claude-Session: https://claude.ai/code/session_01TyRhWbVbbQpQJTDbW8VahJ Co-authored-by: Claude --- .../spec/src/ai/solution-blueprint.test.ts | 171 +++++++++++++++++- .../spec/src/ai/solution-blueprint.zod.ts | 47 ++++- 2 files changed, 213 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index d800146804b..e36431dd04b 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -10,6 +10,8 @@ import { } from './solution-blueprint.zod'; import { ViewFilterRuleSchema } from '../ui/view.zod'; import { SortItemSchema } from '../shared/enums.zod'; +import { SelectOptionSchema } from '../data/field.zod'; +import { ObjectSchema } from '../data/object.zod'; const validBlueprint: SolutionBlueprint = { summary: 'A simple project tracker', @@ -373,6 +375,92 @@ describe('SolutionBlueprintSchema', () => { })).toEqual({ path: ['views', 0, 'sort'], code: 'invalid_type' }); }); }); + + describe('option `kind` + object `stageField` (an AI-built status field ships its lifecycle kinds)', () => { + const LOAN_STAGES = [ + { label: '申请中', value: 'applied', kind: 'open' as const }, + { label: '已放款', value: 'disbursed', kind: 'open' as const }, + { label: '已还清', value: 'repaid', kind: 'won' as const }, + { label: '已核销', value: 'written_off', kind: 'lost' as const }, + ]; + const loanObject = (stages: Array> = LOAN_STAGES, extra: Record = {}) => ({ + name: 'loan', + fields: [ + { name: 'title', type: 'text' as const }, + { name: 'status', type: 'select' as const, options: stages }, + ], + stageField: 'status', + ...extra, + }); + /** The first issue of a failed lenient parse, as `{ path, code }`. */ + const firstIssue = (input: unknown) => { + const result = SolutionBlueprintSchema.safeParse(input); + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + return { path: issue?.path, code: issue?.code }; + }; + + it('carries every option\'s kind and the object\'s stageField through the parse — z.object would strip an undeclared key', () => { + const parsed = SolutionBlueprintSchema.parse({ summary: 's', objects: [loanObject()] }); + expect(parsed.objects[0].stageField).toBe('status'); + expect(parsed.objects[0].fields[1].options?.map((o) => o.kind)).toEqual(['open', 'open', 'won', 'lost']); + }); + + it('keeps both keys optional — a plain select on an object that names no stage field omits them', () => { + const parsed = SolutionBlueprintSchema.parse(validBlueprint); + const task = parsed.objects.find((o) => o.name === 'task'); + expect(task?.stageField).toBeUndefined(); + const status = task?.fields.find((f) => f.name === 'status'); + expect(status?.options?.every((o) => !Object.prototype.hasOwnProperty.call(o, 'kind'))).toBe(true); + }); + + it('reuses SelectOption.kind\'s enum by identity — the blueprint spells no lifecycle vocabulary of its own', () => { + // Identity, not shape: a copy would agree today and drift the day the + // object field's option gains or loses a kind. + const ownEnum = (SelectOptionSchema as any).shape.kind.unwrap(); + const lenientKind = (SolutionBlueprintSchema as any).shape.objects.element.shape.fields.element.shape + .options.unwrap().element.shape.kind; + const strictKind = (SolutionBlueprintStrictSchema as any).shape.objects.element.shape.fields.element.shape + .options.unwrap().element.shape.kind; + expect(lenientKind.unwrap()).toBe(ownEnum); + expect(strictKind.unwrap()).toBe(ownEnum); + expect(ownEnum.options).toEqual(['open', 'won', 'lost']); + }); + + it('refuses a kind outside open / won / lost, at the option\'s own path', () => { + const stages = LOAN_STAGES.map((o) => (o.value === 'repaid' ? { ...o, kind: 'done' } : o)); + expect(firstIssue({ summary: 's', objects: [loanObject(stages)] })) + .toEqual({ path: ['objects', 0, 'fields', 1, 'options', 2, 'kind'], code: 'invalid_value' }); + }); + + it('refuses a stageField that is not a snake_case field name', () => { + expect(firstIssue({ summary: 's', objects: [loanObject(LOAN_STAGES, { stageField: 'Status' })] })) + .toEqual({ path: ['objects', 0, 'stageField'], code: 'invalid_format' }); + }); + + it('carries exactly what the object schema reads: a 1:1 copy parses, and an option missing its kind is refused there', () => { + // The blueprint does not judge the kinds itself (strict structured + // output cannot express a cross-key rule); the object schema the applier + // writes through does. This pins that the two keys land on the object + // schema's own spellings — `stageField` on the object, `kind` on the + // option — so a plain copy is all an applier has to do. + const toObject = (bp: ReturnType) => { + const parsed = SolutionBlueprintSchema.parse({ summary: 's', objects: [bp] }).objects[0]; + return { + name: parsed.name, + fields: Object.fromEntries(parsed.fields.map(({ name, ...field }) => [name, { label: name, ...field }])), + stageField: parsed.stageField, + }; + }; + expect(ObjectSchema.safeParse(toObject(loanObject())).success).toBe(true); + + const forgot = LOAN_STAGES.map((o) => (o.value === 'written_off' ? { label: o.label, value: o.value } : o)); + const refused = ObjectSchema.safeParse(toObject(loanObject(forgot))); + expect(refused.success).toBe(false); + expect(refused.error?.issues.map((i) => ({ path: i.path, code: i.code }))) + .toEqual([{ path: ['fields', 'status', 'options', 3, 'kind'], code: 'custom' }]); + }); + }); }); // The strict mirror is what `generateObject` sends to OpenAI: every property @@ -391,6 +479,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { description: null, sharingModel: null, nameField: null, + stageField: null, fields: [ { name: 'name', label: null, type: 'text', required: null, reference: null, options: null, summaryOperations: null, expression: null }, ], @@ -431,7 +520,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { const badField = { ...strictBp, objects: [ - { name: 'x', label: null, description: null, sharingModel: null, nameField: 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 }] }, ], }; // `f` is missing the (nullable, required) `label` key. @@ -454,6 +543,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { description: null, sharingModel: null, nameField: null, + stageField: null, fields: [ { name: 'task_total', label: '任务总数', type: 'summary', required: null, reference: null, options: null, expression: null, @@ -530,6 +620,59 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { expect(() => SolutionBlueprintStrictSchema.parse(missingKey)).toThrow(); }); + 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, + }); + const STAGES = [ + { label: 'New', value: 'new', kind: 'open' }, + { label: 'Closed Won', value: 'closed_won', kind: 'won' }, + { label: 'Closed Lost', value: 'closed_lost', kind: 'lost' }, + ]; + const withStatus = (options: Array>, stageField: string | null = 'status') => ({ + ...strictBp, + objects: [{ ...strictBp.objects[0], stageField, fields: [...strictBp.objects[0].fields, statusField(options)] }], + }); + /** The issue paths of a failed strict parse. */ + const issuePaths = (input: unknown) => { + const result = SolutionBlueprintStrictSchema.safeParse(input); + expect(result.success).toBe(false); + return result.success ? [] : result.error.issues.map((i) => i.path.join('.')); + }; + + it('carries the stage field and every option\'s kind — the lifecycle is expressible in the structured output', () => { + const parsed = SolutionBlueprintStrictSchema.parse(withStatus(STAGES)); + expect(parsed.objects[0].stageField).toBe('status'); + expect(parsed.objects[0].fields[1].options?.map((o) => o.kind)).toEqual(['open', 'won', 'lost']); + }); + + it('accepts null for both — an object with no stage field, options with no stated kind', () => { + const parsed = SolutionBlueprintStrictSchema.parse( + withStatus(STAGES.map((o) => ({ ...o, kind: null })), null), + ); + expect(parsed.objects[0].stageField).toBeNull(); + expect(parsed.objects[0].fields[1].options?.every((o) => o.kind === null)).toBe(true); + }); + + it('requires both keys to be present (OpenAI strict: every key in `required`)', () => { + const noStageField = withStatus(STAGES); + delete (noStageField.objects[0] as { stageField?: unknown }).stageField; + expect(issuePaths(noStageField)).toEqual(['objects.0.stageField']); + + const noKind = withStatus(STAGES.map((o, i) => (i === 1 ? { label: o.label, value: o.value } : o))); + expect(issuePaths(noKind)).toEqual(['objects.0.fields.1.options.1.kind']); + }); + + it('refuses a kind outside open / won / lost', () => { + expect(issuePaths(withStatus(STAGES.map((o, i) => (i === 1 ? { ...o, kind: 'done' } : o))))) + .toEqual(['objects.0.fields.1.options.1.kind']); + }); + + it('refuses a stageField that is not a snake_case field name', () => { + expect(issuePaths(withStatus(STAGES, 'Status'))).toEqual(['objects.0.stageField']); + }); + }); + it('carries viewName on a strict nav item — nullable, and REQUIRED to be present (OpenAI strict)', () => { // cloud#2150 shape pin. This mirror is the design step's output contract, // so a `viewName` that exists only in the lenient schema is a fix on no @@ -701,14 +844,27 @@ describe('strict mirror ↔ lenient schema — key parity', () => { expect(strictFieldKeys()).toEqual(lenientFieldKeys()); }); + it('the select OPTION schemas carry exactly the same keys', () => { + // One level below the field pin, for the reason the filter-rule pin sits + // below the view pin: both sides declare the option inline, so a key added + // to one is a key the other drops. `kind` is the reason this pin exists — + // with it on the lenient side only, the design model could never state an + // option's lifecycle kind, and the object schema refuses a stage field + // whose options do not each declare one. No deliberate exclusions. + const optionKeys = (root: any) => + Object.keys(root.shape.objects.element.shape.fields.element.shape.options.unwrap().element.shape).sort(); + expect(optionKeys(SolutionBlueprintStrictSchema)).toEqual(optionKeys(SolutionBlueprintSchema)); + expect(optionKeys(SolutionBlueprintSchema)).toEqual(['kind', 'label', 'value']); + }); + it('the OBJECT schemas carry exactly the same keys', () => { // The `nameField` gap proved the field-level pin alone is not enough: the // lenient object schema carried the ADR-0079 record-title key while the // strict mirror did not, so the design-stage model could never author it — // detected by cloud lint, unfixable on the surface that produced it. There // are NO deliberate object-level exclusions (measured: both sides carry - // name/label/description/fields/sharingModel/nameField); if one ever - // becomes deliberate, list it explicitly here with its reason. + // name/label/description/fields/sharingModel/nameField/stageField); if one + // ever becomes deliberate, list it explicitly here with its reason. const lenientObjectKeys = Object.keys( (SolutionBlueprintSchema as any).shape.objects.element.shape, ).sort(); @@ -716,6 +872,10 @@ describe('strict mirror ↔ lenient schema — key parity', () => { (SolutionBlueprintStrictSchema as any).shape.objects.element.shape, ).sort(); expect(strictObjectKeys).toEqual(lenientObjectKeys); + // `stageField` named outright: it is the only trigger of the object + // schema's mandatory-kind rule, so the option key pin above is half a fix + // without it. + expect(lenientObjectKeys).toContain('stageField'); }); it('the NAV ITEM schemas carry exactly the same keys', () => { @@ -962,13 +1122,16 @@ describe('strict mirror ↔ lenient schema — VALUE parity', () => { description: null, sharingModel: null, nameField: null, + stageField: null, fields: [{ name: 'company_size', label: null, type: 'select', required: null, reference: null, - options: optionsWithLeadingDigit, + // Every strict option key present (`kind: null`), so the ONLY defect + // left in this proposal is the leading digit the pin is about. + options: optionsWithLeadingDigit.map((o) => ({ ...o, kind: null })), summaryOperations: null, expression: null, }], diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index 29f9c53754b..d55c5f99efd 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; -import { FieldType } from '../data/field.zod'; +import { FieldType, SelectOptionSchema } from '../data/field.zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { SortDirectionEnum, SortItemSchema } from '../shared/enums.zod'; import { VIEW_FILTER_OPERATORS, ViewFilterRuleSchema } from '../ui/view.zod'; @@ -26,6 +26,32 @@ import { VIEW_FILTER_OPERATORS, ViewFilterRuleSchema } from '../ui/view.zod'; /** Machine-name pattern every object, field and option name in a blueprint must match. */ const SNAKE_CASE = /^[a-z_][a-z0-9_]*$/; +/** + * The lifecycle category a blueprint option declares: `SelectOption.kind`'s own + * enum (`open` / `won` / `lost`), taken from that schema's shape and never + * re-spelled here, so a blueprint can offer no kind the object field's option + * refuses and miss none it accepts. Both option shapes below wrap it in their + * own spelling of "optional" (`.optional()` lenient, `.nullable()` strict). + * + * A function rather than a constant: the lenient shapes build lazily + * ({@link lazySchema}), and a module-level read would force + * `SelectOptionSchema` at import for every consumer of this module. + */ +const optionKindEnum = () => SelectOptionSchema.shape.kind.unwrap(); + +/** + * What the object schema refuses about the field a blueprint object's + * `stageField` names (`refuseStageFieldWithoutKinds`, `data/object.zod.ts`): + * one sentence both object shapes and both option shapes state, so the two + * halves of the rule (the pointer and the kinds) read the same on every side. + */ +const STAGE_FIELD_RULE = + 'The object schema refuses the object unless the field `stageField` names is a single-value `select` or `radio` ' + + 'whose inline options EACH declare `kind`: "open" for a step on the way, "won" for the successful end (a deal\'s ' + + 'Closed Won, a task\'s Done, a case\'s Resolved), "lost" for the unsuccessful end (Closed Lost, Cancelled, ' + + 'Unqualified). Naming a multiselect / checkboxes / tags field that carries options is refused too: a record ' + + 'stands at one stage.'; + /** * One `field op value` comparison in a blueprint predicate. * @@ -90,6 +116,8 @@ export const BlueprintFieldSchema = lazySchema(() => z.object({ options: z.array(z.object({ label: z.string(), value: z.string().regex(SNAKE_CASE), + kind: optionKindEnum().optional() + .describe(`The option's lifecycle category, REQUIRED on EVERY option of the field the object's \`stageField\` names. ${STAGE_FIELD_RULE} Only a single-value \`select\` / \`radio\` field's options take it: omit it on a multiselect / checkboxes / tags field's options, where the field schema refuses it. Absent on any other select option reads as "open".`), })).optional().describe('Choices for select / multiselect / radio fields'), summaryOperations: BlueprintSummaryOperationsSchema.optional() .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.'), @@ -108,6 +136,12 @@ export const BlueprintObjectSchema = lazySchema(() => z.object({ .describe('Org-Wide Default record visibility (OWD) for INTERNAL users — the deliberate sharing choice for this object (ADR-0090). Canonical four only: private (owner-only) | public_read (everyone reads, owner writes) | public_read_write (everyone reads+writes) | controlled_by_parent (derived from the master record — ONLY for an object whose fields include a master_detail reference). SET it when the user\'s description implies a visibility intent — personal/private data (HR, 绩效, salary, 个人隐私) → "private"; shared reference data everyone edits → "public_read_write". Omit to accept the platform\'s deterministic default (business object → public_read_write; master-detail child → controlled_by_parent) — omitting on privacy-sensitive data silently over-shares it.'), nameField: z.string().regex(SNAKE_CASE).optional() .describe('The record title field — which field holds the human-readable name shown on cards, lookup chips, breadcrumbs and search (ADR-0079). Set it to the object\'s text label field (e.g. "product_name"). For a numbered entity (invoice/ticket), set it to a formula field that composes number + name (e.g. "{order_no} · {customer}"). Omitting it lets the platform auto-pick a text field, but declaring it is strongly preferred.'), + // `ObjectSchema.stageField` (ADR-0085), and the ONLY trigger of the object + // schema's mandatory-kind rule: without this key the option `kind` above is + // never required of an AI-built object. The string arm only — the object + // schema's `false` ("no linear lifecycle") has no blueprint spelling. + stageField: z.string().regex(SNAKE_CASE).optional() + .describe(`The lifecycle stage field (ADR-0085) — which \`select\` (or \`radio\`) field of THIS object holds the ordered steps a record moves through (a deal's stage, a ticket's status), drawn as the record path. snake_case, naming one of this object's own fields. ${STAGE_FIELD_RULE} SET it whenever the object has such a status/stage field, and give every one of that field's options its \`kind\`. Omitting it leaves stage detection to a guess from field names (status / stage / state / phase).`), })); export type BlueprintObject = z.input; @@ -340,6 +374,12 @@ const StrictField = z.object({ options: z.array(z.object({ label: z.string().describe('What the user reads on the dropdown — free text in the user\'s own language (「1-49人」, 「已完成」).'), value: strictIdent('The STORED machine value: snake_case, and it may NEVER start with a digit — give it a word prefix instead (「1-49人」 → "size_1_49", 「2024年」 → "year_2024"). The label carries the human wording; this key only has to be a legal identifier.'), + // ⛔ Must stay in lockstep with the lenient `BlueprintFieldSchema.options[].kind`. + // THIS describe is the one the design model reads, and strict mode makes + // the key required, so the model decides on every option. The option + // key-parity pin fails if either side drops it. + kind: optionKindEnum().nullable() + .describe(`The option's lifecycle category, or null. REQUIRED (never null) on EVERY option of the field the object's \`stageField\` names. ${STAGE_FIELD_RULE} Only a single-value \`select\` / \`radio\` field's options take it: null on a multiselect / checkboxes / tags field's options, where the field schema refuses it. Null on any other select option reads as "open".`), })).nullable() .describe('Choices for select-family fields, or null'), summaryOperations: StrictSummaryOperations.nullable() @@ -356,6 +396,11 @@ const StrictObject = z.object({ sharingModel: z.enum(['private', 'public_read', 'public_read_write', 'controlled_by_parent']).nullable() .describe('Org-Wide Default record visibility (OWD) for INTERNAL users (ADR-0090), or null to accept the platform default (business object → public_read_write; master-detail child → controlled_by_parent). SET it when the user\'s description implies a visibility intent: personal/private data (HR, 绩效, salary, 个人隐私) → "private" (owner-only); "public_read" = everyone reads, owner writes; "public_read_write" = everyone reads+writes; "controlled_by_parent" ONLY for an object with a master_detail reference field. Null on privacy-sensitive data silently over-shares it.'), nameField: strictIdentOrNull('The record title field — which field holds the human-readable name shown on cards, lookup chips, breadcrumbs and search (ADR-0079), or null to let the platform auto-pick a text field. Set it to the object\'s text label field (e.g. "product_name") — snake_case. For a numbered entity (invoice/ticket), set it to a formula field that composes number + name (e.g. "{order_no} · {customer}"). Declaring it is strongly preferred over null.'), + // ⛔ Must stay in lockstep with the lenient `BlueprintObjectSchema.stageField`: + // a key present only there is one no proposal can ever contain, and the + // option `kind` above would then never be required of an AI-built object. + // The object key-parity pin fails if either side drops it. + stageField: strictIdentOrNull(`The lifecycle stage field (ADR-0085) — which \`select\` (or \`radio\`) field of THIS object holds the ordered steps a record moves through (a deal's stage, a ticket's status), drawn as the record path — or null. snake_case, naming one of this object's own fields. ${STAGE_FIELD_RULE} SET it whenever the object has such a status/stage field, and give every one of that field's options a non-null \`kind\`. Null leaves stage detection to a guess from field names (status / stage / state / phase).`), }); // One list-view filter rule, strict-shaped. The lenient side reuses From 5790dea6d6a243d049ffd19dbeaf976ddd5d64cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:33:08 +0000 Subject: [PATCH 2/2] chore(spec): regenerate authorable surface and reference docs; changeset for the blueprint kind / stageField keys Claude-Session: https://claude.ai/code/session_01TyRhWbVbbQpQJTDbW8VahJ Co-authored-by: Claude --- .../24198-blueprint-option-kind-stage-field.md | 14 ++++++++++++++ content/docs/references/ai/solution-blueprint.mdx | 15 +++++++++++++-- packages/spec/authorable-surface/ai.json | 1 + 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 .changeset/24198-blueprint-option-kind-stage-field.md diff --git a/.changeset/24198-blueprint-option-kind-stage-field.md b/.changeset/24198-blueprint-option-kind-stage-field.md new file mode 100644 index 00000000000..65448bd474a --- /dev/null +++ b/.changeset/24198-blueprint-option-kind-stage-field.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec/ai): a solution blueprint's select options declare their lifecycle `kind`, and a blueprint object names its `stageField` + +Clause-②: yes + +The object schema already requires a lifecycle `kind` (`open` / `won` / `lost`) on every option of the field an object's `stageField` names. The AI blueprint had no slot for either key, so a model could not say which field was an object's stage or which option ended its lifecycle, and that rule never applied to an AI-built object. Both blueprint shapes now carry both keys. + +- **Option `kind`**, on `BlueprintFieldSchema.options[]` and on the strict structured-output mirror's (`SolutionBlueprintStrictSchema`) field options. It is `SelectOption.kind`'s own enum, taken by reference, so the blueprint accepts exactly the kinds an object field's option accepts. Lenient: optional. Strict: required and nullable (null = no kind stated). +- **Object `stageField`**, on `BlueprintObjectSchema` and the strict mirror's objects: a snake_case name of one of the object's own fields, mapping onto `ObjectSchema.stageField`. Lenient: optional. Strict: required and nullable (null = no stage field). The object schema's `false` value ("no linear lifecycle") has no blueprint spelling. +- Both describes state what the object schema refuses: the field `stageField` names must be a single-value `select` or `radio` whose inline options each declare `kind`. A multiselect / checkboxes / tags field that carries options is refused as a stage field, and `kind` on such a field's options is refused by the field schema. The blueprint does not check these rules itself; the object schema does, when the applier writes the object. +- Additive only: every blueprint that parsed before still parses with the lenient schema. A producer of the STRICT shape must now send `stageField` on each object and `kind` on each option (null when it has none), as with every other key in that mirror. diff --git a/content/docs/references/ai/solution-blueprint.mdx b/content/docs/references/ai/solution-blueprint.mdx index 4c6c520cbcc..1865f30a9dc 100644 --- a/content/docs/references/ai/solution-blueprint.mdx +++ b/content/docs/references/ai/solution-blueprint.mdx @@ -109,7 +109,7 @@ const result = BlueprintAppSchema.parse(data); | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | ✅ | Field data type | | **required** | `boolean` | optional | Whether the field is required | | **reference** | `string` | optional | Target object name for lookup / master_detail relationship fields | -| **options** | `{ label: string; value: string }[]` | optional | Choices for select / multiselect / radio fields | +| **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" for a composed title. 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. | @@ -165,6 +165,14 @@ const result = BlueprintAppSchema.parse(data); * `tags` * `vector` +### Nested Shape: `BlueprintField.options[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | ✅ | | +| **value** | `string` | ✅ | | +| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | The option's lifecycle category, REQUIRED on EVERY option of the field the object's `stageField` names. The object schema refuses the object unless the field `stageField` names is a single-value `select` or `radio` whose inline options EACH declare `kind`: "open" for a step on the way, "won" for the successful end (a deal's Closed Won, a task's Done, a case's Resolved), "lost" for the unsuccessful end (Closed Lost, Cancelled, Unqualified). Naming a multiselect / checkboxes / tags field that carries options is refused too: a record stands at one stage. Only a single-value `select` / `radio` field's options take it: omit it on a multiselect / checkboxes / tags field's options, where the field schema refuses it. Absent on any other select option reads as "open". | + ### Nested Shape: `BlueprintField.summaryOperations` | Property | Type | Required | Description | @@ -206,6 +214,7 @@ const result = BlueprintAppSchema.parse(data); | **fields** | `{ name: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; required?: boolean; … }[]` | ✅ | Fields to create on the object | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users — the deliberate sharing choice for this object (ADR-0090). Canonical four only: private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record — ONLY for an object whose fields include a master_detail reference). SET it when the user's description implies a visibility intent — personal/private data (HR, 绩效, salary, 个人隐私) → "private"; shared reference data everyone edits → "public_read_write". Omit to accept the platform's deterministic default (business object → public_read_write; master-detail child → controlled_by_parent) — omitting on privacy-sensitive data silently over-shares it. | | **nameField** | `string` | optional | The record title field — which field holds the human-readable name shown on cards, lookup chips, breadcrumbs and search (ADR-0079). Set it to the object's text label field (e.g. "product_name"). For a numbered entity (invoice/ticket), set it to a formula field that composes number + name (e.g. "`{order_no}` · `{customer}`"). Omitting it lets the platform auto-pick a text field, but declaring it is strongly preferred. | +| **stageField** | `string` | optional | The lifecycle stage field (ADR-0085) — which `select` (or `radio`) field of THIS object holds the ordered steps a record moves through (a deal's stage, a ticket's status), drawn as the record path. snake_case, naming one of this object's own fields. The object schema refuses the object unless the field `stageField` names is a single-value `select` or `radio` whose inline options EACH declare `kind`: "open" for a step on the way, "won" for the successful end (a deal's Closed Won, a task's Done, a case's Resolved), "lost" for the unsuccessful end (Closed Lost, Cancelled, Unqualified). Naming a multiselect / checkboxes / tags field that carries options is refused too: a record stands at one stage. SET it whenever the object has such a status/stage field, and give every one of that field's options its `kind`. Omitting it leaves stage detection to a guess from field names (status / stage / state / phase). | ### Nested Shape: `BlueprintObject.fields[number]` @@ -216,7 +225,7 @@ const result = BlueprintAppSchema.parse(data); | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field data type | | **required** | `boolean` | optional | Whether the field is required | | **reference** | `string` | optional | Target object name for lookup / master_detail relationship fields | -| **options** | `{ label: string; value: string }[]` | optional | Choices for select / multiselect / radio fields | +| **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" for a composed title. 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. | @@ -334,6 +343,7 @@ Sort field and direction pair | **fields** | `{ name: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; required?: boolean; … }[]` | ✅ | Fields to create on the object | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users — the deliberate sharing choice for this object (ADR-0090). Canonical four only: private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record — ONLY for an object whose fields include a master_detail reference). SET it when the user's description implies a visibility intent — personal/private data (HR, 绩效, salary, 个人隐私) → "private"; shared reference data everyone edits → "public_read_write". Omit to accept the platform's deterministic default (business object → public_read_write; master-detail child → controlled_by_parent) — omitting on privacy-sensitive data silently over-shares it. | | **nameField** | `string` | optional | The record title field — which field holds the human-readable name shown on cards, lookup chips, breadcrumbs and search (ADR-0079). Set it to the object's text label field (e.g. "product_name"). For a numbered entity (invoice/ticket), set it to a formula field that composes number + name (e.g. "`{order_no}` · `{customer}`"). Omitting it lets the platform auto-pick a text field, but declaring it is strongly preferred. | +| **stageField** | `string` | optional | The lifecycle stage field (ADR-0085) — which `select` (or `radio`) field of THIS object holds the ordered steps a record moves through (a deal's stage, a ticket's status), drawn as the record path. snake_case, naming one of this object's own fields. The object schema refuses the object unless the field `stageField` names is a single-value `select` or `radio` whose inline options EACH declare `kind`: "open" for a step on the way, "won" for the successful end (a deal's Closed Won, a task's Done, a case's Resolved), "lost" for the unsuccessful end (Closed Lost, Cancelled, Unqualified). Naming a multiselect / checkboxes / tags field that carries options is refused too: a record stands at one stage. SET it whenever the object has such a status/stage field, and give every one of that field's options its `kind`. Omitting it leaves stage detection to a guess from field names (status / stage / state / phase). | ### Nested Shape: `SolutionBlueprint.views[number]` @@ -399,6 +409,7 @@ Sort field and direction pair | **fields** | `{ name: string; label: string \| null; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; required: boolean \| null; … }[]` | ✅ | Fields to create on the object | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'> \| null` | ✅ | Org-Wide Default record visibility (OWD) for INTERNAL users (ADR-0090), or null to accept the platform default (business object → public_read_write; master-detail child → controlled_by_parent). SET it when the user's description implies a visibility intent: personal/private data (HR, 绩效, salary, 个人隐私) → "private" (owner-only); "public_read" = everyone reads, owner writes; "public_read_write" = everyone reads+writes; "controlled_by_parent" ONLY for an object with a master_detail reference field. Null on privacy-sensitive data silently over-shares it. | | **nameField** | `string \| null` | ✅ | The record title field — which field holds the human-readable name shown on cards, lookup chips, breadcrumbs and search (ADR-0079), or null to let the platform auto-pick a text field. Set it to the object's text label field (e.g. "product_name") — snake_case. For a numbered entity (invoice/ticket), set it to a formula field that composes number + name (e.g. "`{order_no}` · `{customer}`"). Declaring it is strongly preferred over null. | +| **stageField** | `string \| null` | ✅ | The lifecycle stage field (ADR-0085) — which `select` (or `radio`) field of THIS object holds the ordered steps a record moves through (a deal's stage, a ticket's status), drawn as the record path — or null. snake_case, naming one of this object's own fields. The object schema refuses the object unless the field `stageField` names is a single-value `select` or `radio` whose inline options EACH declare `kind`: "open" for a step on the way, "won" for the successful end (a deal's Closed Won, a task's Done, a case's Resolved), "lost" for the unsuccessful end (Closed Lost, Cancelled, Unqualified). Naming a multiselect / checkboxes / tags field that carries options is refused too: a record stands at one stage. SET it whenever the object has such a status/stage field, and give every one of that field's options a non-null `kind`. Null leaves stage detection to a guess from field names (status / stage / state / phase). | ### Nested Shape: `SolutionBlueprintStrict.views[number]` diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json index a6eb58384e0..63ab9814a7a 100644 --- a/packages/spec/authorable-surface/ai.json +++ b/packages/spec/authorable-surface/ai.json @@ -67,6 +67,7 @@ "ai/BlueprintObject:name", "ai/BlueprintObject:nameField", "ai/BlueprintObject:sharingModel", + "ai/BlueprintObject:stageField", "ai/BlueprintSeed:object", "ai/BlueprintSeed:records", "ai/BlueprintSummaryOperations:conditions",