Skip to content

Commit 065ee30

Browse files
hotlongclaude
andcommitted
fix(spec): the blueprint mirror the model generates against carries the applier's SNAKE_CASE constraint
The strict structured-output mirror and the lenient authoring schema are two declarations of one shape. An existing test pinned their KEYS; nothing pinned the constraints on those keys, and 20 identifier leaves had drifted — every one of them `.regex(SNAKE_CASE)` on the lenient side and unconstrained on the mirror. A design model could therefore emit `1_49` (from a 「1-49人」 label) and `apply_blueprint`, which validates against the lenient schema, refused the whole blueprint on the turn the user approved it. Every identifier leaf in the mirror now reuses the same regex, so the pattern rides into the JSON Schema the model is given and an out-of-pattern identifier is refused at generation rather than after approval. Option `value` states the leading-digit rule explicitly (「1-49人」 → `size_1_49`); the `label` is untouched, so only the stored value becomes an identifier. A VALUE-parity test walks both schemas leaf by leaf, the twin of the key-parity gate that already guards this pair. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent c99449a commit 065ee30

3 files changed

Lines changed: 182 additions & 24 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+
The model-facing solution-blueprint mirror can no longer generate an identifier the applier rejects.
6+
7+
`SolutionBlueprintSchema` (what `apply_blueprint` validates against) and `SolutionBlueprintStrictSchema` (the OpenAI-strict structured-output contract the design model generates against) are two declarations of one shape. Their KEYS were pinned by an existing parity test; their VALUES had never been. Every identifier in the lenient schema carried `.regex(/^[a-z_][a-z0-9_]*$/)` and not one identifier in the strict mirror carried it — 20 leaves apart, measured.
8+
9+
The consequence was a build whose approval did nothing. Asked for a CRM, the design model emitted a `company_size` select whose option values came straight off the labels — `1_49` for 「1-49人」. Generating that was legal. Applying it was not: on the turn the user clicked 「确认,开始搭建」 the deterministic confirm replay handed that exact blueprint to `apply_blueprint`, which refused it wholesale (`objects.0.fields.2.options.0.value: Invalid string: must match pattern /^[a-z_][a-z0-9_]*$/`) and staged nothing. The app appeared only because the model noticed the error card and retried with a repaired blueprint the user had never seen.
10+
11+
Every identifier leaf in the strict mirror now carries the same `SNAKE_CASE` constraint the lenient schema enforces — object / field / view / dashboard / widget / app / nav names, `reference`, `nameField`, `columns`, `groupBy`, `measure`, roll-up `object` / `field` / `relationshipField`, condition `field`, and select option `value`. The constraint is emitted into the JSON Schema the model is given (`pattern`), so an out-of-pattern identifier is refused at generation instead of after approval. Option `value` additionally spells out the case that produced the incident: it may never start with a digit, so 「1-49人」 is authored as `size_1_49` — the `label` keeps the human wording untouched, and only the stored value is an identifier.
12+
13+
A new `strict mirror ↔ lenient schema — VALUE parity` test walks both schemas leaf by leaf and fails on any future divergence, the value-side twin of the key-parity gate that already guards this pair.
14+
15+
Refs cloud#1967.

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

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,3 +535,132 @@ describe('strict mirror ↔ lenient schema — key parity', () => {
535535
expect(parsed.objects[0].fields[2].expression).toBe("record.order_no + ' · ' + record.customer");
536536
});
537537
});
538+
539+
// ---------------------------------------------------------------------------
540+
// VALUE parity — the twin of the key-parity gate above (cloud#1967).
541+
//
542+
// The key gate pins WHICH keys each side carries. Nothing pinned the
543+
// CONSTRAINTS on those keys, and they had drifted: every identifier in the
544+
// lenient schema carried `.regex(SNAKE_CASE)`, the strict mirror carried none.
545+
// So the model could legally GENERATE `1_49` for a select option value (from a
546+
// 「1-49人」 label) and `apply_blueprint` — which validates against the LENIENT
547+
// schema — then rejected the very blueprint the user had approved:
548+
// objects.0.fields.2.options.0.value: Invalid string: must match pattern
549+
// /^[a-z_][a-z0-9_]*$/
550+
// Nothing was staged, the approval became a no-op, and the build landed only
551+
// because the model happened to retry with a repaired blueprint the user never
552+
// saw. Two declarations of one contract, disagreeing about values.
553+
// ---------------------------------------------------------------------------
554+
describe('strict mirror ↔ lenient schema — VALUE parity (cloud#1967)', () => {
555+
/** The regex a zod string leaf enforces, or null when it enforces none. */
556+
const patternOf = (schema: any): string | null => {
557+
const checks = schema?.def?.checks;
558+
if (!Array.isArray(checks)) return null;
559+
for (const c of checks) {
560+
const p = c?._zod?.def?.pattern;
561+
if (p) return String(p);
562+
}
563+
return null;
564+
};
565+
566+
/**
567+
* Index every string leaf reachable from `schema` by its authoring path,
568+
* mapping it to the pattern it enforces. Wrapper nodes (optional / nullable /
569+
* default / lazy) are transparent so the lenient `.optional()` and the strict
570+
* `.nullable()` spelling of the same key land on the SAME path.
571+
*/
572+
const indexPatterns = (schema: any): Map<string, string | null> => {
573+
const out = new Map<string, string | null>();
574+
const seen = new Set<unknown>();
575+
const walk = (node: any, path: string, depth: number): void => {
576+
if (!node || depth > 12) return;
577+
const def = node.def;
578+
if (!def) return;
579+
switch (def.type) {
580+
case 'optional':
581+
case 'nullable':
582+
case 'default':
583+
case 'prefault':
584+
case 'nonoptional':
585+
case 'readonly':
586+
return walk(def.innerType, path, depth + 1);
587+
case 'lazy':
588+
return walk(def.getter(), path, depth + 1);
589+
case 'array':
590+
return walk(def.element, `${path}[]`, depth + 1);
591+
case 'object': {
592+
if (seen.has(def.shape)) return;
593+
seen.add(def.shape);
594+
for (const [key, child] of Object.entries(def.shape as Record<string, unknown>)) {
595+
walk(child, path ? `${path}.${key}` : key, depth + 1);
596+
}
597+
return;
598+
}
599+
case 'string':
600+
out.set(path, patternOf(node));
601+
return;
602+
default:
603+
return; // unions / enums / records / numbers carry no identifier pattern
604+
}
605+
};
606+
walk(schema, '', 0);
607+
return out;
608+
};
609+
610+
it('every identifier the applier constrains is constrained the same way in the model-facing mirror', () => {
611+
const lenient = indexPatterns(SolutionBlueprintSchema);
612+
const strict = indexPatterns(SolutionBlueprintStrictSchema);
613+
// Only paths BOTH sides carry are comparable — the key-parity gate above
614+
// owns "which keys exist"; this one owns "what values they accept".
615+
const drift = [...lenient.entries()]
616+
.filter(([path]) => strict.has(path))
617+
.filter(([path, pattern]) => strict.get(path) !== pattern)
618+
.map(([path, pattern]) => `${path}: lenient ${pattern ?? 'none'} vs strict ${strict.get(path) ?? 'none'}`);
619+
expect(drift).toEqual([]);
620+
});
621+
622+
it('the model cannot emit the leading-digit option value the applier rejects', () => {
623+
// The exact value from the live run: a 「1-49人」 company-size band.
624+
const optionsWithLeadingDigit = [
625+
{ label: '1-49人', value: '1_49' },
626+
{ label: '50-199人', value: '50_199' },
627+
];
628+
const lenient = SolutionBlueprintSchema.safeParse({
629+
summary: 's',
630+
objects: [{
631+
name: 'customer',
632+
fields: [{ name: 'company_size', type: 'select', options: optionsWithLeadingDigit }],
633+
}],
634+
});
635+
expect(lenient.success).toBe(false);
636+
637+
const strict = SolutionBlueprintStrictSchema.safeParse({
638+
summary: 's',
639+
assumptions: [],
640+
questions: null,
641+
objects: [{
642+
name: 'customer',
643+
label: null,
644+
description: null,
645+
sharingModel: null,
646+
nameField: null,
647+
fields: [{
648+
name: 'company_size',
649+
label: null,
650+
type: 'select',
651+
required: null,
652+
reference: null,
653+
options: optionsWithLeadingDigit,
654+
summaryOperations: null,
655+
expression: null,
656+
}],
657+
}],
658+
views: null,
659+
dashboards: null,
660+
app: null,
661+
});
662+
// Before cloud#1967 this parse SUCCEEDED — the proposal was legal to
663+
// generate and illegal to apply.
664+
expect(strict.success).toBe(false);
665+
});
666+
});

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

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -256,30 +256,47 @@ export function defineSolutionBlueprint(config: z.input<typeof SolutionBlueprint
256256
// SolutionBlueprintSchema} (and every existing consumer/test) is unchanged.
257257
// ---------------------------------------------------------------------------
258258

259+
// Identifier leaves in this mirror carry the SAME `SNAKE_CASE` constraint the
260+
// lenient schema above enforces (cloud#1967). They had drifted: the mirror is
261+
// what the model may GENERATE, the lenient schema is what `apply_blueprint`
262+
// VALIDATES, so an identifier legal here and illegal there produced a proposal
263+
// the applier rejected wholesale — `1_49` from a 「1-49人」 label, staging
264+
// nothing on the turn the user clicked 「确认,开始搭建」. Declaring the constraint
265+
// on the generating side makes that value impossible to propose rather than
266+
// impossible to apply. The `strict mirror ↔ lenient schema — VALUE parity` test
267+
// walks both schemas leaf by leaf and fails on any future divergence.
268+
const strictIdent = (description: string) => z.string().regex(SNAKE_CASE).describe(description);
269+
/** The same identifier, `.nullable()` — this mirror's spelling of "optional". */
270+
const strictIdentOrNull = (description: string) =>
271+
z.string().regex(SNAKE_CASE).nullable().describe(description);
272+
259273
// The roll-up config, strict-shaped: every key present, "optional" → nullable,
260274
// and the predicate as a flat `conditions` ARRAY because strict mode cannot
261275
// express the canonical `filter` map (open-ended additionalProperties). The
262276
// blueprint tools compile `conditions` back into a real query filter.
263277
const StrictSummaryOperations = z.object({
264-
object: z.string().describe('The CHILD object whose records are aggregated (snake_case). It MUST have a lookup/master_detail field pointing back at this parent.'),
278+
object: strictIdent('The CHILD object whose records are aggregated (snake_case). It MUST have a lookup/master_detail field pointing back at this parent.'),
265279
function: z.enum(['count', 'sum', 'avg', 'min', 'max']).describe('Aggregation: "数量/个数/计数" → count; "合计/总额/累计" → sum; "平均" → avg'),
266-
field: z.string().nullable().describe('Numeric field on the CHILD to aggregate; null (or "id") for count'),
267-
relationshipField: z.string().nullable().describe('Child FK field back to this parent, or null to auto-detect'),
280+
field: strictIdentOrNull('Numeric field on the CHILD to aggregate; null (or "id") for count'),
281+
relationshipField: strictIdentOrNull('Child FK field back to this parent, or null to auto-detect'),
268282
conditions: z.array(z.object({
269-
field: z.string().describe('Field on the CHILD object'),
283+
field: strictIdent('Field on the CHILD object'),
270284
op: z.enum(['lt', 'lte', 'gt', 'gte', 'eq', 'ne']).describe('Comparison operator'),
271285
value: z.union([z.number(), z.string(), z.boolean()]).describe('Comparison value — a select field\'s option VALUE, never its label'),
272286
})).nullable()
273287
.describe('CONDITIONAL roll-up: aggregate only child rows matching these (ANDed), or null to aggregate every child. REQUIRED whenever the field name carries a qualifier ("已完成任务数 / 已收货金额 / 待处理工单数", any 已X / 未X / <某状态>的 count-or-sum) — e.g. [{field:"status",op:"eq",value:"completed"}]. Without it the roll-up counts EVERYTHING and reports a plausible WRONG number.'),
274288
});
275289

276290
const StrictField = z.object({
277-
name: z.string().describe('Field machine name (snake_case)'),
291+
name: strictIdent('Field machine name (snake_case)'),
278292
label: z.string().nullable().describe('Human-readable field label, or null'),
279293
type: FieldType.describe('Field data type'),
280294
required: z.boolean().nullable().describe('Whether the field is required, or null'),
281-
reference: z.string().nullable().describe('Target object for lookup/master_detail, or null'),
282-
options: z.array(z.object({ label: z.string(), value: z.string() })).nullable()
295+
reference: strictIdentOrNull('Target object for lookup/master_detail, or null'),
296+
options: z.array(z.object({
297+
label: z.string().describe('What the user reads on the dropdown — free text in the user\'s own language (「1-49人」, 「已完成」).'),
298+
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.'),
299+
})).nullable()
283300
.describe('Choices for select-family fields, or null'),
284301
summaryOperations: StrictSummaryOperations.nullable()
285302
.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.'),
@@ -288,39 +305,36 @@ const StrictField = z.object({
288305
});
289306

290307
const StrictObject = z.object({
291-
name: z.string().describe('Object machine name (snake_case)'),
308+
name: strictIdent('Object machine name (snake_case)'),
292309
label: z.string().nullable().describe('Human-readable singular label, or null'),
293310
description: z.string().nullable().describe('What this object represents, or null'),
294311
fields: z.array(StrictField).describe('Fields to create on the object'),
295312
sharingModel: z.enum(['private', 'public_read', 'public_read_write', 'controlled_by_parent']).nullable()
296313
.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.'),
297-
nameField: z.string().nullable()
298-
.describe('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.'),
314+
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.'),
299315
});
300316

301317
const StrictView = z.object({
302-
object: z.string().describe('Object this view displays (snake_case)'),
303-
name: z.string().describe('View machine name (snake_case)'),
318+
object: strictIdent('Object this view displays (snake_case)'),
319+
name: strictIdent('View machine name (snake_case)'),
304320
label: z.string().nullable().describe('Human-readable view label, or null'),
305321
type: z.enum(['list', 'form', 'kanban', 'calendar', 'gallery', 'gantt']).nullable().describe('View kind, or null for list. "gallery" = visual card/cover browse (画廊/相册/卡片墙/封面/海报, or an object with an image/avatar/file field); "gantt" = timeline/schedule (甘特图/时间线/排期, object with BOTH a start and an end date field); "kanban" = board grouped by a status/select field; "calendar" = single-date schedule; "form" = record editor.'),
306-
columns: z.array(z.string()).nullable().describe('Field names shown as columns, or null. For a gallery, INCLUDE the image/avatar/file field (becomes the card cover); for a gantt, INCLUDE the start date column before the end date column.'),
307-
groupBy: z.string().nullable().describe('REQUIRED for kanban: the select/status field whose options become the board columns (e.g. "stage"). Optional for gantt (groups leaf tasks). Null for list/form/calendar/gallery.'),
322+
columns: z.array(z.string().regex(SNAKE_CASE)).nullable().describe('Field names shown as columns, or null. For a gallery, INCLUDE the image/avatar/file field (becomes the card cover); for a gantt, INCLUDE the start date column before the end date column.'),
323+
groupBy: strictIdentOrNull('REQUIRED for kanban: the select/status field whose options become the board columns (e.g. "stage"). Optional for gantt (groups leaf tasks). Null for list/form/calendar/gallery.'),
308324
});
309325

310326
const StrictDashboard = z.object({
311-
name: z.string().describe('Dashboard machine name (snake_case)'),
327+
name: strictIdent('Dashboard machine name (snake_case)'),
312328
label: z.string().nullable().describe('Human-readable dashboard label, or null'),
313329
widgets: z.array(z.object({
314-
id: z.string().describe('Widget id (snake_case)'),
330+
id: strictIdent('Widget id (snake_case)'),
315331
title: z.string().nullable().describe('Widget title, or null'),
316-
object: z.string().nullable().describe('Source object, or null'),
332+
object: strictIdentOrNull('Source object, or null'),
317333
chart: z.enum(['metric', 'bar', 'line', 'pie', 'table']).nullable().describe('Visualization, or null'),
318-
measure: z.string().nullable()
319-
.describe('The field this widget aggregates (e.g. "amount", "probability"), or "count" to count records, or null to infer from the title. The aggregation (sum vs average) is chosen automatically from the field type — name the FIELD, not "total_amount". "total revenue" → "amount"; "average win rate" → "win_rate"; "number of deals" → "count".'),
320-
groupBy: z.string().nullable()
321-
.describe('The field to break the widget down by — the category or time axis (e.g. "stage", "created_at"), or null for a single-number metric. A "by status" chart MUST set this to the status field; the title and this field MUST name the SAME field.'),
334+
measure: strictIdentOrNull('The field this widget aggregates (e.g. "amount", "probability"), or "count" to count records, or null to infer from the title. The aggregation (sum vs average) is chosen automatically from the field type — name the FIELD, not "total_amount". "total revenue" → "amount"; "average win rate" → "win_rate"; "number of deals" → "count".'),
335+
groupBy: strictIdentOrNull('The field to break the widget down by — the category or time axis (e.g. "stage", "created_at"), or null for a single-number metric. A "by status" chart MUST set this to the status field; the title and this field MUST name the SAME field.'),
322336
condition: z.object({
323-
field: z.string().describe('Field on the widget object to filter by (e.g. "stock_quantity", "status")'),
337+
field: strictIdent('Field on the widget object to filter by (e.g. "stock_quantity", "status")'),
324338
op: z.enum(['lt', 'lte', 'gt', 'gte', 'eq', 'ne']).describe('Comparison operator'),
325339
value: z.union([z.number(), z.string(), z.boolean()]).describe('Comparison value (e.g. 10, "open")'),
326340
}).nullable()
@@ -330,13 +344,13 @@ const StrictDashboard = z.object({
330344

331345
const StrictNavItem = z.object({
332346
type: z.enum(['object', 'dashboard']).describe('What this nav entry opens'),
333-
target: z.string().describe('Object or dashboard machine name to surface (snake_case)'),
347+
target: strictIdent('Object or dashboard machine name to surface (snake_case)'),
334348
label: z.string().nullable().describe('Nav entry label, or null'),
335349
icon: z.string().nullable().describe('Lucide icon name, or null'),
336350
});
337351

338352
const StrictApp = z.object({
339-
name: z.string().describe('App machine name (snake_case)'),
353+
name: strictIdent('App machine name (snake_case)'),
340354
label: z.string().nullable().describe('App display label, or null'),
341355
icon: z.string().nullable().describe('Lucide icon for the App Launcher, or null'),
342356
nav: z.array(StrictNavItem).nullable()

0 commit comments

Comments
 (0)