Skip to content

Commit 8333a6c

Browse files
hotlongclaude
andauthored
feat(spec): a blueprint nav entry can name the VIEW it opens (cloud#2150) (#16841)
* feat(spec): a blueprint nav entry can name the VIEW it opens (cloud#2150) `BlueprintNavItemSchema` gets an optional `viewName`, and the OpenAI-strict mirror's `StrictNavItem` gets it in lockstep (required-but-nullable, per the strict convention). Until now the shape could only say which OBJECT a menu entry opens. A model that had just designed a kanban and wanted it in the app menu therefore had exactly one move left: emit a SECOND entry at the same `target` and carry the intent in `label`/`icon` alone. Both entries then opened the object's default view, and the cloud applier derives a nav id from the target, so the two collided — the user clicked 「工单看板」 and got the list. Nothing anywhere complained: the target object really does exist, so the dangling-target lint had nothing to say. The runtime nav item could always express this (`ObjectNavItemSchema.viewName`, "Default list view to open"), so the gap was the blueprint's alone and the duplicate entry was the reasonable move under the expressiveness the model was given. Both halves, deliberately. A key on the lenient side only is a fix on no path the product runs: `propose_blueprint` validates its structured output against the mirror, so the design step could never author a board entry while `apply_blueprint` accepted a key nothing emits — the `seedData` divergence (cloud#2118) with a different key name. `viewName` is deliberately un-regexed on BOTH sides, unlike `target`: a view answers to a bare key (`views[].name`) and to the qualified `<object>.<key>` a staged view record carries, and the applier normalizes between them. Constraining the leaf would make one spelling legal to generate and illegal to apply — the cloud#1967 shape, where an already-approved blueprint was refused wholesale. The strict↔lenient key-parity pin is widened one level further out (fields → objects → NAV ITEMS); ablating the mirror half fails four tests. Generated authorable-surface and the blueprint reference are regenerated. Part of objectstack-ai/cloud#2150. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(spec): classify the blueprint nav `viewName` empty state as `scope` (cloud#2150) The empty-state gate refuses a permissive empty state nobody signed off on, and the strict mirror's describe states one out loud ('or null for the object's default list'). It is presentation breadth, not authorization: the entry's `target` already fixes WHICH OBJECT is reachable, record- and field-level security are enforced downstream, and the empty state is the behaviour every existing app already has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 923caed commit 8333a6c

6 files changed

Lines changed: 157 additions & 3 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
A blueprint nav entry can say WHICH view it opens: `viewName` is added to `BlueprintNavItemSchema` and, in lockstep, to the strict mirror's `StrictNavItem` (required-but-nullable, per the strict convention).
6+
7+
Without it the shape could only say which OBJECT an entry opens, so a model that had just designed a kanban and wanted it in the menu had one move left: emit a SECOND entry at the same `target` and carry the intent in `label`/`icon` alone. Both entries then opened the object's default view, and the consumer derived both ids from the target, so they collided — the user clicked 「工单看板」 and got the list, with nothing to see anywhere (the target object really exists, so a dangling-target lint has nothing to say). The runtime nav item could always express this — `ObjectNavItemSchema.viewName` is "Default list view to open" — so the gap was the blueprint's alone, and the model's duplicate entry was the reasonable move under the expressiveness it was given.
8+
9+
`viewName` is deliberately NOT `.regex(SNAKE_CASE)` on either side, unlike `target`. A view answers to two interchangeable spellings — the bare key a blueprint's `views[].name` carries and the qualified `<object>.<key>` a staged view record's `name` carries — and consumers normalize between them. Constraining the leaf would make one spelling legal to GENERATE and illegal to APPLY, the failure mode that once refused an already-approved blueprint wholesale.
10+
11+
The key-parity pin between the strict mirror and the lenient schema is widened a level further out — fields → objects → NAV ITEMS — so the next nav-level divergence fails a test rather than shipping as "the lenient side accepts a key no proposal can contain".

‎content/docs/references/ai/solution-blueprint.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const result = BlueprintAppSchema.parse(data);
3030
| **name** | `string` | ✅ | App machine name (snake_case) |
3131
| **label** | `string` | optional | App display label |
3232
| **icon** | `string` | optional | Lucide icon for the App Launcher |
33-
| **nav** | `{ type: Enum<'object' \| 'dashboard'>; target: string; label?: string; icon?: string }[]` | optional | Navigation entries; omit to auto-surface every created object and dashboard |
33+
| **nav** | `{ type: Enum<'object' \| 'dashboard'>; target: string; label?: string; icon?: string; … }[]` | optional | Navigation entries; omit to auto-surface every created object and dashboard |
3434

3535
### Nested Shape: `BlueprintApp.nav[number]`
3636

@@ -40,6 +40,7 @@ const result = BlueprintAppSchema.parse(data);
4040
| **target** | `string` | ✅ | Object or dashboard machine name to surface (snake_case) |
4141
| **label** | `string` | optional | Nav entry label (defaults to the target label/name) |
4242
| **icon** | `string` | optional | Lucide icon name for the nav entry |
43+
| **viewName** | `string` | optional | For type:"object" only — the `views[].name` this entry opens (e.g. "ticket_status_board"). Omit for the object's default list. SET it whenever this blueprint authors a kanban/calendar/gallery/gantt view the menu should reach: give the object ONE entry per view (a 「工单列表」 entry with no viewName plus a 「工单看板」 entry with viewName:"ticket_status_board"). Without it every entry on the same target opens the SAME default list, and a label/icon saying otherwise is decoration. |
4344

4445

4546
---
@@ -173,6 +174,7 @@ const result = BlueprintAppSchema.parse(data);
173174
| **target** | `string` | ✅ | Object or dashboard machine name to surface (snake_case) |
174175
| **label** | `string` | optional | Nav entry label (defaults to the target label/name) |
175176
| **icon** | `string` | optional | Lucide icon name for the nav entry |
177+
| **viewName** | `string` | optional | For type:"object" only — the `views[].name` this entry opens (e.g. "ticket_status_board"). Omit for the object's default list. SET it whenever this blueprint authors a kanban/calendar/gallery/gantt view the menu should reach: give the object ONE entry per view (a 「工单列表」 entry with no viewName plus a 「工单看板」 entry with viewName:"ticket_status_board"). Without it every entry on the same target opens the SAME default list, and a label/icon saying otherwise is decoration. |
176178

177179

178180
---
@@ -323,7 +325,7 @@ const result = BlueprintAppSchema.parse(data);
323325
| **name** | `string` | ✅ | App machine name (snake_case) |
324326
| **label** | `string` | optional | App display label |
325327
| **icon** | `string` | optional | Lucide icon for the App Launcher |
326-
| **nav** | `{ type: Enum<'object' \| 'dashboard'>; target: string; label?: string; icon?: string }[]` | optional | Navigation entries; omit to auto-surface every created object and dashboard |
328+
| **nav** | `{ type: Enum<'object' \| 'dashboard'>; target: string; label?: string; icon?: string; … }[]` | optional | Navigation entries; omit to auto-surface every created object and dashboard |
327329

328330
### Nested Shape: `SolutionBlueprint.seedData[number]`
329331

@@ -386,7 +388,7 @@ const result = BlueprintAppSchema.parse(data);
386388
| **name** | `string` | ✅ | App machine name (snake_case) |
387389
| **label** | `string \| null` | ✅ | App display label, or null |
388390
| **icon** | `string \| null` | ✅ | Lucide icon for the App Launcher, or null |
389-
| **nav** | `{ type: Enum<'object' \| 'dashboard'>; target: string; label: string \| null; icon: string \| null }[] \| null` | ✅ | Navigation entries; null to auto-surface every created object and dashboard |
391+
| **nav** | `{ type: Enum<'object' \| 'dashboard'>; target: string; label: string \| null; icon: string \| null; … }[] \| null` | ✅ | Navigation entries; null to auto-surface every created object and dashboard |
390392

391393

392394
---

‎packages/spec/authorable-surface/ai.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"ai/BlueprintNavItem:label",
6161
"ai/BlueprintNavItem:target",
6262
"ai/BlueprintNavItem:type",
63+
"ai/BlueprintNavItem:viewName",
6364
"ai/BlueprintObject:description",
6465
"ai/BlueprintObject:fields",
6566
"ai/BlueprintObject:label",

‎packages/spec/scripts/liveness/empty-state-registry.mts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [
179179
rationale:
180180
'Which error a catch node handles; empty catches all of them. Catching broadly is the safe direction for an error handler — the failure mode of the opposite default is an unhandled fault, not an over-grant.',
181181
},
182+
{
183+
file: 'packages/spec/src/ai/solution-blueprint.zod.ts',
184+
property: 'viewName',
185+
semantics: 'scope',
186+
rationale:
187+
"Which VIEW a proposed app-menu entry opens; absent/null means the object's default list. It is presentation breadth, not authorization — the entry's `target` already fixes WHICH OBJECT is reachable, and record- and field-level security are enforced downstream, so naming a view can only ever change how the same permitted rows are laid out. The empty state is also the pre-existing behaviour every app already has, which is what makes it the safe default here (cloud#2150 added the key precisely because there was no way to say anything ELSE). Note the failure direction is cosmetic and visible: an entry labelled 「工单看板」 that omits this opens the list — wrong-looking, never over-granting.",
188+
},
182189
{
183190
file: 'packages/spec/src/ai/knowledge-source.zod.ts',
184191
property: 'mimeTypes',

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,56 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
439439
expect(() => SolutionBlueprintStrictSchema.parse(missingKey)).toThrow();
440440
});
441441

442+
it('carries viewName on a strict nav item — nullable, and REQUIRED to be present (OpenAI strict)', () => {
443+
// cloud#2150 shape pin. This mirror is the design step's output contract,
444+
// so a `viewName` that exists only in the lenient schema is a fix on no
445+
// path the product actually runs: `propose_blueprint` could never author a
446+
// board entry, and `apply_blueprint` would accept a key nothing emits.
447+
const navShape = (SolutionBlueprintStrictSchema as any).shape.app.unwrap().shape.nav.unwrap().element.shape;
448+
expect('viewName' in navShape).toBe(true);
449+
450+
// null is accepted (the entry opens the object's default list)…
451+
const bare = SolutionBlueprintStrictSchema.parse({
452+
...strictBp,
453+
app: { name: 'ticketing', label: null, icon: null, nav: [
454+
{ type: 'object', target: 'ticket', label: '工单列表', icon: 'list', viewName: null },
455+
] },
456+
});
457+
expect(bare.app?.nav?.[0].viewName).toBeNull();
458+
459+
// …and the whole point of the card: list AND board for ONE object, told
460+
// apart by the view each entry opens rather than by decoration.
461+
const board = SolutionBlueprintStrictSchema.parse({
462+
...strictBp,
463+
app: { name: 'ticketing', label: null, icon: null, nav: [
464+
{ type: 'object', target: 'ticket', label: '工单列表', icon: 'list', viewName: null },
465+
{ type: 'object', target: 'ticket', label: '工单看板', icon: 'kanban', viewName: 'ticket_status_board' },
466+
] },
467+
});
468+
expect(board.app?.nav?.map((n) => n.viewName)).toEqual([null, 'ticket_status_board']);
469+
470+
// …and OMITTING the key throws (strict mode: every key in `required`).
471+
const missingKey = {
472+
...strictBp,
473+
app: { name: 'ticketing', label: null, icon: null, nav: [{ type: 'object', target: 'ticket', label: null, icon: null }] },
474+
};
475+
expect(() => SolutionBlueprintStrictSchema.parse(missingKey)).toThrow();
476+
});
477+
478+
it('accepts the QUALIFIED <object>.<view> spelling too — the applier normalizes, the mirror must not refuse', () => {
479+
// `viewName` is deliberately un-regexed on BOTH sides: a view answers to a
480+
// bare key and to the `<object>.<key>` name its staged record carries.
481+
// Constraining it here would make one of the two legal to generate and
482+
// illegal to apply (cloud#1967).
483+
const parsed = SolutionBlueprintStrictSchema.parse({
484+
...strictBp,
485+
app: { name: 'ticketing', label: null, icon: null, nav: [
486+
{ type: 'object', target: 'ticket', label: '工单看板', icon: 'kanban', viewName: 'ticket.ticket_status_board' },
487+
] },
488+
});
489+
expect(parsed.app?.nav?.[0].viewName).toBe('ticket.ticket_status_board');
490+
});
491+
442492
it('accepts a dashboard widget carrying the (nullable) measure + groupBy + condition keys', () => {
443493
const parsed = SolutionBlueprintStrictSchema.parse({
444494
...strictBp,
@@ -511,6 +561,56 @@ describe('strict mirror ↔ lenient schema — key parity', () => {
511561
expect(strictObjectKeys).toEqual(lenientObjectKeys);
512562
});
513563

564+
it('the NAV ITEM schemas carry exactly the same keys', () => {
565+
// Widened for cloud#2150, the third time the same gap was found one level
566+
// further out (fields → objects → nav items). The nav item is where "which
567+
// VIEW does this menu entry open" lives; with `viewName` on one side only,
568+
// the design step could not author a board entry (mirror missing) or the
569+
// applier would drop one it was handed (lenient missing). There are NO
570+
// deliberate nav-level exclusions; if one ever becomes deliberate, list it
571+
// explicitly here with its reason.
572+
const lenientNavKeys = Object.keys(
573+
(SolutionBlueprintSchema as any).shape.app.unwrap().shape.nav.unwrap().element.shape,
574+
).sort();
575+
const strictNavKeys = Object.keys(
576+
(SolutionBlueprintStrictSchema as any).shape.app.unwrap().shape.nav.unwrap().element.shape,
577+
).sort();
578+
expect(strictNavKeys).toEqual(lenientNavKeys);
579+
});
580+
581+
it('carries `viewName`, so a menu entry can say WHICH view it opens', () => {
582+
// Guards the specific hole (cloud#2150): this surface can always author a
583+
// kanban view AND a nav entry, so without a way to bind the two the model's
584+
// only remaining move is a duplicate entry distinguished by label alone —
585+
// which is what shipped, silently, to users.
586+
const lenientNavKeys = Object.keys(
587+
(SolutionBlueprintSchema as any).shape.app.unwrap().shape.nav.unwrap().element.shape,
588+
);
589+
const strictNavKeys = Object.keys(
590+
(SolutionBlueprintStrictSchema as any).shape.app.unwrap().shape.nav.unwrap().element.shape,
591+
);
592+
expect(lenientNavKeys).toContain('viewName');
593+
expect(strictNavKeys).toContain('viewName');
594+
});
595+
596+
it('round-trips a list + board pair on ONE object through the lenient schema', () => {
597+
const parsed = SolutionBlueprintSchema.parse({
598+
summary: 's',
599+
objects: [{ name: 'ticket', fields: [{ name: 'title', type: 'text' }, { name: 'status', type: 'select' }] }],
600+
views: [{ object: 'ticket', name: 'ticket_status_board', type: 'kanban', groupBy: 'status' }],
601+
app: {
602+
name: 'ticket_management',
603+
nav: [
604+
{ type: 'object', target: 'ticket', label: '工单列表', icon: 'list' },
605+
{ type: 'object', target: 'ticket', label: '工单看板', icon: 'kanban', viewName: 'ticket_status_board' },
606+
],
607+
},
608+
});
609+
// The negative control rides along: the entry that named no view keeps
610+
// carrying none, so the default-list behaviour is untouched.
611+
expect(parsed.app?.nav?.map((n) => n.viewName)).toEqual([undefined, 'ticket_status_board']);
612+
});
613+
514614
it('carries `expression`, so a formula field can state what it computes', () => {
515615
// Guards the specific hole: `FieldType` includes `formula`, so this surface
516616
// can always NAME one. If it cannot also carry the body, every formula it

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,35 @@ export type BlueprintDashboard = z.input<typeof BlueprintDashboardSchema>;
160160
* A proposed navigation item in the blueprint app — points at one of the
161161
* created objects or dashboards. `apply_blueprint` expands it into the full
162162
* `AppSchema` nav item (object → list view, dashboard → dashboard view).
163+
*
164+
* `viewName` is what makes "list AND board for the same table" expressible
165+
* (cloud#2150). Without it this shape could only say WHICH OBJECT an entry
166+
* opens, so a model that had just designed a kanban and wanted it in the menu
167+
* had exactly one move left: emit a SECOND entry at the same `target`, with the
168+
* intent carried only by `label`/`icon`. Both entries then opened the object's
169+
* default view, and the cloud applier derived both ids from the target, so they
170+
* collided — the user clicked 「工单看板」 and got the list, with no error
171+
* anywhere (the target object really does exist, so the lint had nothing to
172+
* say). The runtime could always express this: `ObjectNavItemSchema.viewName`
173+
* is "Default list view to open". Only the blueprint could not, which is why
174+
* the model's behaviour was reasonable and the schema was the defect.
175+
*
176+
* Deliberately NOT `.regex(SNAKE_CASE)`, unlike `target`. A view is identified
177+
* downstream in two interchangeable spellings — the bare key a blueprint's
178+
* `views[].name` carries (`ticket_status_board`) and the qualified
179+
* `<object>.<key>` a staged view record's `name` carries — and the applier
180+
* normalizes between them. Constraining this leaf to snake_case would make the
181+
* qualified spelling legal to GENERATE (a strict mirror is per-leaf) and
182+
* illegal to APPLY, which is the cloud#1967 failure mode: a blueprint the user
183+
* already approved, refused wholesale at apply time.
163184
*/
164185
export const BlueprintNavItemSchema = lazySchema(() => z.object({
165186
type: z.enum(['object', 'dashboard']).default('object').describe('What this nav entry opens'),
166187
target: z.string().regex(SNAKE_CASE).describe('Object or dashboard machine name to surface (snake_case)'),
167188
label: z.string().optional().describe('Nav entry label (defaults to the target label/name)'),
168189
icon: z.string().optional().describe('Lucide icon name for the nav entry'),
190+
viewName: z.string().optional()
191+
.describe('For type:"object" only — the `views[].name` this entry opens (e.g. "ticket_status_board"). Omit for the object\'s default list. SET it whenever this blueprint authors a kanban/calendar/gallery/gantt view the menu should reach: give the object ONE entry per view (a 「工单列表」 entry with no viewName plus a 「工单看板」 entry with viewName:"ticket_status_board"). Without it every entry on the same target opens the SAME default list, and a label/icon saying otherwise is decoration.'),
169192
}));
170193
export type BlueprintNavItem = z.input<typeof BlueprintNavItemSchema>;
171194
/** Post-parse shape of {@link BlueprintNavItem} — defaults applied, transforms run (ADR-0122). */
@@ -347,6 +370,16 @@ const StrictNavItem = z.object({
347370
target: strictIdent('Object or dashboard machine name to surface (snake_case)'),
348371
label: z.string().nullable().describe('Nav entry label, or null'),
349372
icon: z.string().nullable().describe('Lucide icon name, or null'),
373+
// ⛔ Must stay in lockstep with the lenient `BlueprintNavItemSchema.viewName`
374+
// (cloud#2150). THIS side is the one that decides whether the design step can
375+
// author a board entry at all: `propose_blueprint`'s structured output is
376+
// validated against this mirror, so a key present only in the lenient schema
377+
// is a fix that exists nowhere on the main path — the applier would accept a
378+
// `viewName` no proposal can ever contain. That is the `seedData` divergence
379+
// (cloud#2118) with a different key name; the nav key-parity pin below fails
380+
// if this line is removed.
381+
viewName: z.string().nullable()
382+
.describe('For type:"object" only — the `views[].name` this entry opens (e.g. "ticket_status_board"), or null for the object\'s default list. SET it whenever this blueprint authors a kanban/calendar/gallery/gantt view the menu should reach: give the object ONE entry per view (a 「工单列表」 entry with viewName null plus a 「工单看板」 entry with viewName "ticket_status_board"). Null on every entry means they all open the SAME default list, and a label/icon saying otherwise is decoration.'),
350383
});
351384

352385
const StrictApp = z.object({

0 commit comments

Comments
 (0)