Skip to content

Commit efa4e80

Browse files
committed
feat(spec): a blueprint formula field can state its 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 OpenAI-strict mirror the model generates against had any key for the body. There was no way, anywhere on that surface, to say what the formula computed. It materialized bare, and cloud graph-lint then correctly reported `formula_without_expression` with the fix "Set field expression to a CEL formula" — which the agent could not write in the blueprint it was holding. Detected, but unfixable on the surface that produced it. This is the same hole `summaryOperations` closed for roll-ups in cloud#970; `formula` was left behind. It bites hardest through `nameField`, whose own guidance tells the model to use a formula for numbered entities (invoice / ticket) composing "number · name" — following that advice produced a record title blank on every card, lookup chip and breadcrumb. The PIN matters more than the key. The root cause is not a forgotten property: 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, and drift either way 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. `authorable-surface.json` records exactly one new key (`ai/BlueprintField:expression`). The API surface is unchanged — a Zod field adds no exports. Verification: blueprint tests 29/29; full suite 132/132; check:api-surface unchanged; check:authorable-surface green after gen:schema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY
1 parent 85a966f commit efa4e80

4 files changed

Lines changed: 75 additions & 4 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A blueprint `formula` field can finally say what it computes: `BlueprintFieldSchema` and its OpenAI-strict mirror both gain `expression`.
6+
7+
`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.
8+
9+
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.
10+
11+
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.
12+
13+
**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.
14+
15+
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.

packages/spec/authorable-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"ai/BlueprintDashboard:label",
4848
"ai/BlueprintDashboard:name",
4949
"ai/BlueprintDashboard:widgets",
50+
"ai/BlueprintField:expression",
5051
"ai/BlueprintField:label",
5152
"ai/BlueprintField:name",
5253
"ai/BlueprintField:options",

packages/spec/src/ai/solution-blueprint.test.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
245245
label: null,
246246
description: null,
247247
fields: [
248-
{ name: 'name', label: null, type: 'text', required: null, reference: null, options: null, summaryOperations: null },
248+
{ name: 'name', label: null, type: 'text', required: null, reference: null, options: null, summaryOperations: null, expression: null },
249249
],
250250
},
251251
],
@@ -284,7 +284,7 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
284284
const badField = {
285285
...strictBp,
286286
objects: [
287-
{ name: 'x', label: null, description: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null, summaryOperations: null }] },
287+
{ name: 'x', label: null, description: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null, summaryOperations: null, expression: null }] },
288288
],
289289
};
290290
// `f` is missing the (nullable, required) `label` key.
@@ -307,11 +307,11 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
307307
description: null,
308308
fields: [
309309
{
310-
name: 'task_total', label: '任务总数', type: 'summary', required: null, reference: null, options: null,
310+
name: 'task_total', label: '任务总数', type: 'summary', required: null, reference: null, options: null, expression: null,
311311
summaryOperations: { object: 'task', function: 'count', field: 'id', relationshipField: null, conditions: null },
312312
},
313313
{
314-
name: 'completed_task_count', label: '已完成任务数', type: 'summary', required: null, reference: null, options: null,
314+
name: 'completed_task_count', label: '已完成任务数', type: 'summary', required: null, reference: null, options: null, expression: null,
315315
summaryOperations: {
316316
object: 'task', function: 'count', field: 'id', relationshipField: null,
317317
conditions: [{ field: 'status', op: 'eq', value: 'completed' }],
@@ -360,3 +360,54 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
360360
expect(() => SolutionBlueprintStrictSchema.parse(missingKeys)).toThrow();
361361
});
362362
});
363+
364+
// ---------------------------------------------------------------------------
365+
// The mirror and the lenient schema must not diverge.
366+
//
367+
// This pin is the actual lesson of the `formula` gap it was added with. The
368+
// blueprint could name `type: 'formula'` (it uses the full `FieldType` enum)
369+
// but had no `expression` key in EITHER schema — so a model could declare a
370+
// formula field and had no way, anywhere on this surface, to say what it
371+
// computes. It materialized runtime-dead, cloud's graph-lint correctly flagged
372+
// `formula_without_expression`, and the prescribed fix ("set the expression")
373+
// was unwritable in the blueprint the agent was holding. Detected, but
374+
// unfixable on the surface that produced it.
375+
//
376+
// A key that exists in one schema and not the other is the same defect waiting
377+
// to happen: the mirror is what the model may EMIT, the lenient schema is what
378+
// downstream READS. Drift in either direction silently drops authored config.
379+
// ---------------------------------------------------------------------------
380+
describe('strict mirror ↔ lenient schema — key parity', () => {
381+
const lenientFieldKeys = () =>
382+
Object.keys((SolutionBlueprintSchema as any).shape.objects.element.shape.fields.element.shape).sort();
383+
const strictFieldKeys = () =>
384+
Object.keys((SolutionBlueprintStrictSchema as any).shape.objects.element.shape.fields.element.shape).sort();
385+
386+
it('the field schemas carry exactly the same keys', () => {
387+
expect(strictFieldKeys()).toEqual(lenientFieldKeys());
388+
});
389+
390+
it('carries `expression`, so a formula field can state what it computes', () => {
391+
// Guards the specific hole: `FieldType` includes `formula`, so this surface
392+
// can always NAME one. If it cannot also carry the body, every formula it
393+
// produces is dead on arrival.
394+
expect(lenientFieldKeys()).toContain('expression');
395+
expect(strictFieldKeys()).toContain('expression');
396+
});
397+
398+
it('round-trips a formula field with its expression through the lenient schema', () => {
399+
const parsed = SolutionBlueprintSchema.parse({
400+
summary: 's',
401+
objects: [{
402+
name: 'invoice',
403+
nameField: 'title',
404+
fields: [
405+
{ name: 'order_no', type: 'text' },
406+
{ name: 'customer', type: 'text' },
407+
{ name: 'title', type: 'formula', expression: "record.order_no + ' · ' + record.customer" },
408+
],
409+
}],
410+
});
411+
expect(parsed.objects[0].fields[2].expression).toBe("record.order_no + ' · ' + record.customer");
412+
});
413+
});

packages/spec/src/ai/solution-blueprint.zod.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export const BlueprintFieldSchema = lazySchema(() => z.object({
9090
})).optional().describe('Choices for select / multiselect / radio fields'),
9191
summaryOperations: BlueprintSummaryOperationsSchema.optional()
9292
.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.'),
93+
expression: z.string().optional()
94+
.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.'),
9395
}));
9496
export type BlueprintField = z.infer<typeof BlueprintFieldSchema>;
9597

@@ -271,6 +273,8 @@ const StrictField = z.object({
271273
.describe('Choices for select-family fields, or null'),
272274
summaryOperations: StrictSummaryOperations.nullable()
273275
.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.'),
276+
expression: z.string().nullable()
277+
.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.'),
274278
});
275279

276280
const StrictObject = z.object({

0 commit comments

Comments
 (0)