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
19 changes: 19 additions & 0 deletions .changeset/21248-strict-nav-label-describe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': patch
---

fix(spec): the strict blueprint nav item's `label` describe says `null` inherits the target's current label

Clause-②: no

`SolutionBlueprintStrictSchema` is the output contract the AI design step generates against, and
strict mode makes every nav entry's `label` a required decision. Its describe read only "Nav entry
label, or null", so nothing the model reads said which of the two choices follows a rename of the
target, and the model was steered toward writing one. The describe now states the lenient
`BlueprintNavItemSchema.label` rule in the strict spelling: `null` ⇒ the entry inherits the CURRENT
label of what it opens at render time (a renamed target shows its new name); a string ⇒ rendered
verbatim, never a copy of the target's label. Write a label only when the entry must read
differently from what it opens.

Describe text only: the key stays `z.string().nullable()`, so the schema accepts and refuses the
same blueprints. A pin holds the lenient and strict `label` describes to one rule.
20 changes: 20 additions & 0 deletions packages/spec/src/ai/solution-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,26 @@ describe('strict mirror ↔ lenient schema — key parity', () => {
expect(strictNavKeys).toContain('viewName');
});

it('states ONE `label` rule on both sides: the empty spelling inherits, a written label is verbatim', () => {
// cloud#2021. The lenient describe said what an absent label means; the
// strict mirror — the describe the design model actually reads — said only
// "or null", so nothing on the generating side told the model that null is
// the choice that follows a rename of the target. Each side spells "empty"
// its own way (`absent` / `null`) and "written" its own way (`present` /
// `a string`); what each spelling MEANS must be the same text on both.
const navLabelDescribe = (schema: any): string =>
schema.shape.app.unwrap().shape.nav.unwrap().element.shape.label.description;
const rule = (describe: string) => ({
empty: describe.match(/\b(?:absent|null) ⇒ ([^;]+);/)?.[1],
written: describe.match(/\b(?:present|a string) ⇒ ([^.]+)\./)?.[1],
});
const lenient = rule(navLabelDescribe(SolutionBlueprintSchema));
const strict = rule(navLabelDescribe(SolutionBlueprintStrictSchema));
expect(strict.empty).toBeDefined();
expect(strict.written).toBeDefined();
expect(strict).toEqual(lenient);
});

it('round-trips a list + board pair on ONE object through the lenient schema', () => {
const parsed = SolutionBlueprintSchema.parse({
summary: 's',
Expand Down
13 changes: 12 additions & 1 deletion packages/spec/src/ai/solution-blueprint.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,18 @@ const StrictDashboard = z.object({
const StrictNavItem = z.object({
type: z.enum(['object', 'dashboard']).describe('What this nav entry opens'),
target: strictIdent('Object or dashboard machine name to surface (snake_case)'),
label: z.string().nullable().describe('Nav entry label, or null'),
// ⛔ Must state the SAME rule as the lenient `BlueprintNavItemSchema.label`
// (cloud#2021). THIS describe is the one the design model reads:
// `propose_blueprint`'s structured output is generated against this mirror,
// and strict mode makes `label` required, so the model decides on every
// entry. The blueprint tools strip its `null` to an absent label, which the
// renderer resolves to the target's CURRENT label on every render; a written
// string is rendered verbatim and never follows a rename. A describe that
// does not say which choice inherits steers the model to write a label on
// every entry. The nav `label` rule pin in `solution-blueprint.test.ts`
// (beside the `viewName` key-parity pin) fails if the two drift.
label: z.string().nullable()
.describe('Nav entry label, or null. null ⇒ the entry inherits the CURRENT label of what it opens at render time (a renamed target shows its new name); a string ⇒ rendered verbatim, so never copy the target\'s label in as a default. Write a label ONLY when the entry must read differently from what it opens; otherwise null.'),
icon: z.string().nullable().describe('Lucide icon name, or null'),
// ⛔ Must stay in lockstep with the lenient `BlueprintNavItemSchema.viewName`
// (cloud#2150). THIS side is the one that decides whether the design step can
Expand Down
Loading