You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
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.
// The roll-up config, strict-shaped: every key present, "optional" → nullable,
260
274
// and the predicate as a flat `conditions` ARRAY because strict mode cannot
261
275
// express the canonical `filter` map (open-ended additionalProperties). The
262
276
// blueprint tools compile `conditions` back into a real query filter.
263
277
constStrictSummaryOperations=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.'),
value: z.union([z.number(),z.string(),z.boolean()]).describe('Comparison value — a select field\'s option VALUE, never its label'),
272
286
})).nullable()
273
287
.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.'),
274
288
});
275
289
276
290
constStrictField=z.object({
277
-
name: z.string().describe('Field machine name (snake_case)'),
291
+
name: strictIdent('Field machine name (snake_case)'),
278
292
label: z.string().nullable().describe('Human-readable field label, or null'),
279
293
type: FieldType.describe('Field data type'),
280
294
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'),
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()
283
300
.describe('Choices for select-family fields, or null'),
.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.'),
.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.'),
299
315
});
300
316
301
317
constStrictView=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)'),
304
320
label: z.string().nullable().describe('Human-readable view label, or null'),
305
321
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.'),
308
324
});
309
325
310
326
constStrictDashboard=z.object({
311
-
name: z.string().describe('Dashboard machine name (snake_case)'),
327
+
name: strictIdent('Dashboard machine name (snake_case)'),
312
328
label: z.string().nullable().describe('Human-readable dashboard label, or null'),
313
329
widgets: z.array(z.object({
314
-
id: z.string().describe('Widget id (snake_case)'),
330
+
id: strictIdent('Widget id (snake_case)'),
315
331
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'),
317
333
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.'),
322
336
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")'),
0 commit comments