Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/blueprint-formula-expression.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/references/ai/solution-blueprint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


---
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 55 additions & 4 deletions packages/spec/src/ai/solution-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
},
],
Expand Down Expand Up @@ -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.
Expand All @@ -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' }],
Expand Down Expand Up @@ -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");
});
});
4 changes: 4 additions & 0 deletions packages/spec/src/ai/solution-blueprint.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof BlueprintFieldSchema>;

Expand Down Expand Up @@ -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({
Expand Down
Loading