diff --git a/.changeset/21248-strict-nav-label-describe.md b/.changeset/21248-strict-nav-label-describe.md new file mode 100644 index 00000000000..8e3e384cd6a --- /dev/null +++ b/.changeset/21248-strict-nav-label-describe.md @@ -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. diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index 2be0ee655bb..e63bde02f5f 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -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', diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index fa064e5cc7c..6ec5a0076a6 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -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