diff --git a/.changeset/blueprint-formula-expression.md b/.changeset/blueprint-formula-expression.md new file mode 100644 index 0000000000..5b980414c6 --- /dev/null +++ b/.changeset/blueprint-formula-expression.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +--- + +A blueprint `formula` field can finally say what it computes: `BlueprintFieldSchema` and its OpenAI-strict mirror both gain `expression`. + +`BlueprintFieldSchema.type` is the **full** `FieldType` enum, so the AI-build design step could always NAME a `formula` field — but neither the lenient schema nor the strict mirror the model generates against had any key for the body. There was no way, anywhere on that surface, to state what the formula computed. It materialized bare, and cloud's graph-lint then correctly reported `formula_without_expression` with the fix *"Set field expression to a CEL formula"* — a fix the agent could not write in the blueprint it was holding. Detected, but unfixable on the surface that produced it. + +This is the exact hole `summaryOperations` closed for roll-ups in cloud#970 (see this file's own test: *"z.object STRIPS unknown keys, so before this slot existed a blueprint that correctly declared `{ type:'summary', summaryOperations:{…} }` lost the config at the parse waist and materialized runtime-dead"*). `formula` was simply left behind — the same defect, one field type over. + +It bites hardest through `nameField`, whose own guidance tells the model to point at a formula for numbered entities (invoice/ticket) that compose `number · name`. Without an expression slot, following that advice produces a record title that is blank on every card, lookup chip and breadcrumb. + +**The pin matters more than the key.** A1's root cause is not a forgotten property — it is that two schemas describe the same shape and nothing forced them to agree. The mirror is what the model may EMIT; the lenient schema is what downstream READS. Drift in either direction silently drops authored config. A new test asserts the two field schemas carry **exactly** the same keys, so the next key added to one cannot go missing from the other. + +Cloud's `objectBody` carries the value through to materialization (companion change in the `cloud` repo); it reads the key via cast, as it already does for `defaultValue`, so it is inert against an older spec and live as soon as this ships. diff --git a/content/docs/references/ai/solution-blueprint.mdx b/content/docs/references/ai/solution-blueprint.mdx index 913d1ef64e..678fdc8b51 100644 --- a/content/docs/references/ai/solution-blueprint.mdx +++ b/content/docs/references/ai/solution-blueprint.mdx @@ -96,6 +96,7 @@ const result = BlueprintApp.parse(data); | **reference** | `string` | optional | Target object name for lookup / master_detail relationship fields | | **options** | `{ label: string; value: string }[]` | 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. | --- diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 5272fb7ca2..2f8949391e 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -47,6 +47,7 @@ "ai/BlueprintDashboard:label", "ai/BlueprintDashboard:name", "ai/BlueprintDashboard:widgets", + "ai/BlueprintField:expression", "ai/BlueprintField:label", "ai/BlueprintField:name", "ai/BlueprintField:options", diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index 27b4bf732d..f389e79d10 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -245,7 +245,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { label: null, description: null, fields: [ - { name: 'name', label: null, type: 'text', required: null, reference: null, options: null, summaryOperations: null }, + { name: 'name', label: null, type: 'text', required: null, reference: null, options: null, summaryOperations: null, expression: null }, ], }, ], @@ -284,7 +284,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { const badField = { ...strictBp, objects: [ - { name: 'x', label: null, description: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null, summaryOperations: null }] }, + { name: 'x', label: null, description: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null, summaryOperations: null, expression: null }] }, ], }; // `f` is missing the (nullable, required) `label` key. @@ -307,11 +307,11 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { description: null, fields: [ { - name: 'task_total', label: '任务总数', type: 'summary', required: null, reference: null, options: null, + name: 'task_total', label: '任务总数', type: 'summary', required: null, reference: null, options: null, expression: 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, + name: 'completed_task_count', label: '已完成任务数', type: 'summary', required: null, reference: null, options: null, expression: null, summaryOperations: { object: 'task', function: 'count', field: 'id', relationshipField: null, conditions: [{ field: 'status', op: 'eq', value: 'completed' }], @@ -360,3 +360,54 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { expect(() => SolutionBlueprintStrictSchema.parse(missingKeys)).toThrow(); }); }); + +// --------------------------------------------------------------------------- +// The mirror and the lenient schema must not diverge. +// +// This pin is the actual lesson of the `formula` gap it was added with. The +// blueprint could name `type: 'formula'` (it uses the full `FieldType` enum) +// but had no `expression` key in EITHER schema — so a model could declare a +// formula field and had no way, anywhere on this surface, to say what it +// computes. It materialized runtime-dead, cloud's graph-lint correctly flagged +// `formula_without_expression`, and the prescribed fix ("set the expression") +// was unwritable in the blueprint the agent was holding. Detected, but +// unfixable on the surface that produced it. +// +// A key that exists in one schema and not the other is the same defect waiting +// to happen: the mirror is what the model may EMIT, the lenient schema is what +// downstream READS. Drift in either direction silently drops authored config. +// --------------------------------------------------------------------------- +describe('strict mirror ↔ lenient schema — key parity', () => { + const lenientFieldKeys = () => + Object.keys((SolutionBlueprintSchema as any).shape.objects.element.shape.fields.element.shape).sort(); + const strictFieldKeys = () => + Object.keys((SolutionBlueprintStrictSchema as any).shape.objects.element.shape.fields.element.shape).sort(); + + it('the field schemas carry exactly the same keys', () => { + expect(strictFieldKeys()).toEqual(lenientFieldKeys()); + }); + + it('carries `expression`, so a formula field can state what it computes', () => { + // Guards the specific hole: `FieldType` includes `formula`, so this surface + // can always NAME one. If it cannot also carry the body, every formula it + // produces is dead on arrival. + expect(lenientFieldKeys()).toContain('expression'); + expect(strictFieldKeys()).toContain('expression'); + }); + + it('round-trips a formula field with its expression through the lenient schema', () => { + const parsed = SolutionBlueprintSchema.parse({ + summary: 's', + objects: [{ + name: 'invoice', + nameField: 'title', + fields: [ + { name: 'order_no', type: 'text' }, + { name: 'customer', type: 'text' }, + { name: 'title', type: 'formula', expression: "record.order_no + ' · ' + record.customer" }, + ], + }], + }); + expect(parsed.objects[0].fields[2].expression).toBe("record.order_no + ' · ' + record.customer"); + }); +}); diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index 790b6c53d8..6dd90c7b3f 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -90,6 +90,8 @@ export const BlueprintFieldSchema = lazySchema(() => z.object({ })).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.'), + 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" 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.'), })); export type BlueprintField = z.infer; @@ -271,6 +273,8 @@ const StrictField = z.object({ .describe('Choices for select-family fields, or null'), summaryOperations: StrictSummaryOperations.nullable() .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" for a composed record title; null for every other field type. 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.'), }); const StrictObject = z.object({