Skip to content

Commit 800bdb0

Browse files
os-zhuangclaude
andauthored
feat(spec)!: the Studio authoring surface closes (#4001 batch 7) (#4541)
All 27 shapes across `studio/` — plugin manifests, the flow builder, the object designer. These are the configs a Studio extension author writes by hand, and a dropped key here is quiet in the way this campaign cares about: the plugin loads, the canvas renders, the designer opens, each contributing less than its author declared. A viewer that never appears in the switcher looks like a registration bug, not a spelling one. The triage verdicts were provisional and verifying them found one wrong. All three files carried `(p)`; `plugin.zod.ts` was `mixed` with an EMPTY note, which is how an unexamined label survives. Reading it settles it: all eight shapes are contribution points on a hand-written manifest, no wire half. The method is the part worth keeping, because the original triage had none — each file exports a `define*` factory that parses an author-written literal, and a `define*` factory IS the authoring door. Same lens the registered-type batches used, and cheaper than reasoning about who consumes the output. Recorded in the ledger for the next row that needs promoting out of `(p)`. The manifest gets the guidance it invites: this file says outright that it is "the package.json equivalent" and that `contributes` is "analogous to VS Code's". That analogy is the point and the hazard — every near-miss is a word that is correct over there. `displayName` → `name`, `publisher` → `author`, `contributions` → `contributes`; and for keys with no counterpart (`main`, `engines`, `categories`, `keywords`, `repository`, `icon`, `dependencies`) a sentence saying what to use instead. `main` is the dangerous one: an author declares an entry point, gets a plugin that loads and contributes nothing, and it looks exactly like a broken `activate()`. What this checkout could not settle is stated in the ledger rather than glossed: whether objectui also CONSTRUCTS these configs programmatically and parses them with extra internal keys. If it does, strictness makes that a loud 422 at its build, with the rename suggested, instead of the silent narrowing it replaces. Verified: full monorepo suite 132/132 tasks, spec 7241 tests, tsc clean, 8 generated artifacts current, all 10 spec gates green. Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48fbacb commit 800bdb0

5 files changed

Lines changed: 230 additions & 35 deletions

File tree

.changeset/studio-strict.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
The Studio authoring surface rejects unknown keys — plugin manifests, the flow builder, and the object designer.
6+
7+
All 27 shapes across `studio/` close. These are the configs a Studio extension author writes by hand, and a dropped key here is quiet in the way this campaign cares about: the plugin loads, the canvas renders, the designer opens — each contributing less than its author declared. A viewer that never appears in the switcher looks like a registration bug, not a spelling one.
8+
9+
**The plugin manifest gets the guidance that matters, because this file invites the mistake itself.** It says outright that the manifest is "the `package.json` equivalent" and that `contributes` is "analogous to VS Code's". That analogy is the point *and* the hazard: an author who knows VS Code reaches for its vocabulary, and every near-miss is a word that is correct over there. `displayName``name`, `publisher``author`, `contributions``contributes`, `activation``activationEvents`; and for the keys with no counterpart at all — `main`, `engines`, `categories`, `keywords`, `repository`, `icon`, `dependencies` — a sentence saying what to use instead.
10+
11+
`main` is the one worth calling out. An author declares an entry point, gets a plugin that loads and contributes nothing, and it looks exactly like a broken `activate()`. The rejection now says there is no entry-point key: contributions are declared in the manifest, runtime components are registered imperatively in `activate()`.
12+
13+
**The triage verdicts were provisional, and verifying them found one wrong.** All three files carried `(p)` from the original pass, and `plugin.zod.ts` was `mixed` — with an empty note, which is how an unexamined label survives. Reading it settles the question: all eight shapes are contribution points on a hand-written manifest. There is no wire half.
14+
15+
The method is worth keeping, because the original triage had none: **each file exports a `define*` factory that parses an author-written literal, and a `define*` factory is the authoring door.** That is the same lens the registered-type batches used, and it is cheaper than reasoning about who consumes the output. Recorded in the ledger for the next row that needs promoting out of `(p)`.
16+
17+
What this checkout could not settle, stated in the ledger rather than glossed: whether `objectui` also *constructs* these configs programmatically and parses them with extra internal keys. If it does, strictness turns that into a loud 422 at its build — detectable, with the rename suggested — rather than the silent narrowing it replaces.

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -509,9 +509,29 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
509509

510510
| File | Sites | Class | Note |
511511
|---|---|---|---|
512-
| `object-designer.zod.ts` | 12 | authorable (p) | Studio-written JSON — machine-authored but *our* machine; strict protects the builder itself |
513-
| `plugin.zod.ts` | 8 | mixed (p) | |
514-
| `flow-builder.zod.ts` | 7 | authorable (p) | independent of `FlowSchema` shapes |
512+
| `object-designer.zod.ts` | 12 | authorable | strict as of #4001`defineObjectDesignerConfig` is the authoring door |
513+
| `plugin.zod.ts` | 8 | authorable | strict as of #4001**was `mixed (p)`; verification found no wire half** |
514+
| `flow-builder.zod.ts` | 7 | authorable | strict as of #4001`defineFlowBuilderConfig`; independent of `FlowSchema` |
515+
516+
**All three provisional verdicts are now verified, and one was wrong.** The
517+
deciding evidence is the same lens the registered-type batches used: each file
518+
exports a `define*` factory (`defineStudioPlugin`,
519+
`defineFlowBuilderConfig`, `defineObjectDesignerConfig`) that `.parse()`s an
520+
author-written literal. That is the authoring door, so `authorable` holds without
521+
needing to know what `objectui` does with the result.
522+
523+
`plugin.zod.ts` was carried as `mixed (p)` with an empty note — a verdict with no
524+
stated reason, which is how a provisional label survives. Reading it settles the
525+
question: all eight shapes are contribution points on a VS Code-style plugin
526+
manifest, every one hand-written by a plugin author. There is no wire half.
527+
528+
What could NOT be verified from this checkout, stated rather than glossed:
529+
whether `objectui` also *constructs* these configs programmatically and parses
530+
them with extra internal keys. If it does, strictness turns that into a loud 422
531+
at its build — detectable and fixable, with the rename suggested — rather than
532+
the silent narrowing it replaces. That is the trade this whole campaign makes,
533+
and the residual risk is named here so a reader with `objectui` access can close
534+
it rather than rediscover it.
515535

516536
## Other directories (coarse; classify per schema before touching)
517537

@@ -576,11 +596,19 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
576596
if the answer is "still nothing". A wait that is never re-examined is
577597
indistinguishable from an abandoned one.
578598

579-
2. `studio/` is the largest untouched authorable block — 27 sites, **0 strict**,
580-
and all three files still carry a provisional `(p)` from the original triage.
581-
Not blocked on field data (Studio-written JSON is our own producer, so the
582-
downstream risk is the lowest on the board); it is simply unstarted. If the
583-
step-1 question comes back "nothing is reporting", start here instead.
599+
2. ~~`studio/` is the largest untouched authorable block — 27 sites, **0
600+
strict**, and all three files still carry a provisional `(p)`.~~ **DONE
601+
(#4001).** All 27 sites strict; the three provisional verdicts verified, and
602+
`plugin.zod.ts`'s `mixed (p)` corrected to `authorable` — see the `studio/`
603+
table above for the evidence and for the one thing this checkout could not
604+
settle.
605+
606+
Worth noting how the verdicts were settled, because the original triage had
607+
no method for it: each file exports a `define*` factory that parses an
608+
author-written literal. **A `define*` factory is the authoring door** — the
609+
same lens the registered-type batches used, and a cheaper one than reasoning
610+
about who consumes the output. When a future triage row needs promoting out
611+
of `(p)`, look for the factory first.
584612

585613
Done in the registered-types batch: `strictObject` (`shared/strict-object.ts`)
586614
replaced the four-part wiring recipe, and `seed` + `doc` became the first two

packages/spec/src/studio/flow-builder.zod.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ import { z } from 'zod';
4040
* Matches BPMN conventions where applicable.
4141
*/
4242
import { lazySchema } from '../shared/lazy-schema';
43+
import { strictObject } from '../shared/strict-object';
44+
45+
/**
46+
* Shared history for this file (#4001).
47+
*
48+
* Canvas configuration is visual, so a dropped key produces a canvas — just not
49+
* the one that was configured. The author looks for the mistake in their layout,
50+
* not in their spelling.
51+
*/
52+
const FLOW_BUILDER_HISTORY =
53+
'Until #4001 closed these shapes an unknown key was dropped silently — the builder still '
54+
+ 'rendered, without whatever the key was meant to configure.';
55+
4356
export const FlowNodeShapeSchema = lazySchema(() => z.enum([
4457
'rounded_rect', // Default activity shape (assignments, CRUD, HTTP, script, subflow)
4558
'circle', // Start / End events
@@ -57,7 +70,10 @@ export type FlowNodeShape = z.infer<typeof FlowNodeShapeSchema>;
5770
* Maps each FlowNodeAction to its canvas rendering descriptor.
5871
* Used by the Studio flow canvas to determine shape, icon, and default size.
5972
*/
60-
export const FlowNodeRenderDescriptorSchema = lazySchema(() => z.object({
73+
export const FlowNodeRenderDescriptorSchema = lazySchema(() => strictObject({
74+
surface: 'this flow node rend descriptor',
75+
history: FLOW_BUILDER_HISTORY,
76+
}, {
6177
/** The node action type this descriptor applies to */
6278
action: z.string().describe('FlowNodeAction value (e.g., "parallel_gateway")'),
6379

@@ -98,7 +114,10 @@ export type FlowNodeRenderDescriptor = z.infer<typeof FlowNodeRenderDescriptorSc
98114
/**
99115
* A node instance on the flow canvas, containing position and visual overrides.
100116
*/
101-
export const FlowCanvasNodeSchema = lazySchema(() => z.object({
117+
export const FlowCanvasNodeSchema = lazySchema(() => strictObject({
118+
surface: 'this flow canvas node',
119+
history: FLOW_BUILDER_HISTORY,
120+
}, {
102121
/** Reference to the flow node id */
103122
nodeId: z.string().describe('Corresponding FlowNode.id'),
104123

@@ -149,7 +168,10 @@ export type FlowCanvasEdgeStyle = z.infer<typeof FlowCanvasEdgeStyleSchema>;
149168
/**
150169
* A sequence-flow edge on the flow canvas with visual properties.
151170
*/
152-
export const FlowCanvasEdgeSchema = lazySchema(() => z.object({
171+
export const FlowCanvasEdgeSchema = lazySchema(() => strictObject({
172+
surface: 'this flow canvas edge',
173+
history: FLOW_BUILDER_HISTORY,
174+
}, {
153175
/** Reference to the flow edge id */
154176
edgeId: z.string().describe('Corresponding FlowEdge.id'),
155177

@@ -164,7 +186,11 @@ export const FlowCanvasEdgeSchema = lazySchema(() => z.object({
164186
.describe('Position of the condition label along the edge'),
165187

166188
/** Optional waypoints for routing the edge around nodes */
167-
waypoints: z.array(z.object({
189+
waypoints: z.array(strictObject({
190+
surface: 'this edge waypoint',
191+
history: FLOW_BUILDER_HISTORY,
192+
aliases: { left: 'x', top: 'y', cx: 'x', cy: 'y' },
193+
}, {
168194
x: z.number().describe('Waypoint X'),
169195
y: z.number().describe('Waypoint Y'),
170196
})).optional().describe('Manual waypoints for edge routing'),
@@ -207,17 +233,28 @@ export type FlowLayoutDirection = z.infer<typeof FlowLayoutDirectionSchema>;
207233
* Flow Builder configuration — top-level config for the Studio
208234
* automation flow canvas editor.
209235
*/
210-
export const FlowBuilderConfigSchema = lazySchema(() => z.object({
236+
export const FlowBuilderConfigSchema = lazySchema(() => strictObject({
237+
surface: 'this flow build configuration',
238+
history: FLOW_BUILDER_HISTORY,
239+
}, {
211240
/** Canvas snap settings */
212-
snap: z.object({
241+
snap: strictObject({
242+
surface: 'these snap settings',
243+
history: FLOW_BUILDER_HISTORY,
244+
aliases: { active: 'enabled', on: 'enabled', grid: 'gridSize', size: 'gridSize', step: 'gridSize', visible: 'showGrid', grid_: 'showGrid' },
245+
}, {
213246
enabled: z.boolean().default(true).describe('Enable snap-to-grid'),
214247
gridSize: z.number().int().min(1).default(16).describe('Snap grid size in pixels'),
215248
showGrid: z.boolean().default(true).describe('Show grid overlay'),
216249
}).default({ enabled: true, gridSize: 16, showGrid: true })
217250
.describe('Canvas snap-to-grid settings'),
218251

219252
/** Canvas zoom settings */
220-
zoom: z.object({
253+
zoom: strictObject({
254+
surface: 'these zoom settings',
255+
history: FLOW_BUILDER_HISTORY,
256+
aliases: { minZoom: 'min', maxZoom: 'max', initial: 'default', defaultZoom: 'default', increment: 'step', delta: 'step' },
257+
}, {
221258
min: z.number().min(0.1).default(0.25).describe('Minimum zoom level'),
222259
max: z.number().max(10).default(3).describe('Maximum zoom level'),
223260
default: z.number().default(1).describe('Default zoom level'),

packages/spec/src/studio/object-designer.zod.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,24 @@ import { z } from 'zod';
6868
* in the right-side property inspector.
6969
*/
7070
import { lazySchema } from '../shared/lazy-schema';
71-
export const FieldPropertySectionSchema = lazySchema(() => z.object({
71+
import { strictObject } from '../shared/strict-object';
72+
73+
/**
74+
* Shared history for this file (#4001).
75+
*
76+
* The object designer is the surface where a human shapes the data model, so a
77+
* dropped key here quietly narrows what the designer can express — a field group
78+
* that never renders, a filter that never applies — while the designer itself
79+
* looks like it is working.
80+
*/
81+
const OBJECT_DESIGNER_HISTORY =
82+
'Until #4001 closed these shapes an unknown key was dropped silently — the designer still '
83+
+ 'rendered, without whatever the key was meant to configure.';
84+
85+
export const FieldPropertySectionSchema = lazySchema(() => strictObject({
86+
surface: 'this field property section',
87+
history: OBJECT_DESIGNER_HISTORY,
88+
}, {
7289
/** Unique section key */
7390
key: z.string().describe('Section key (e.g., "basics", "constraints", "security")'),
7491

@@ -91,7 +108,10 @@ export type FieldPropertySection = z.infer<typeof FieldPropertySectionSchema>;
91108
* Field grouping configuration — organizes fields into collapsible groups
92109
* within the field editor table (e.g., "Contact Info", "Billing", "System").
93110
*/
94-
export const FieldGroupSchema = lazySchema(() => z.object({
111+
export const FieldGroupSchema = lazySchema(() => strictObject({
112+
surface: 'this field group',
113+
history: OBJECT_DESIGNER_HISTORY,
114+
}, {
95115
/** Group key (matches field.group value) */
96116
key: z.string().describe('Group key matching field.group values'),
97117

@@ -113,7 +133,10 @@ export type FieldGroup = z.infer<typeof FieldGroupSchema>;
113133
/**
114134
* Field Editor configuration — controls the visual field editing experience.
115135
*/
116-
export const FieldEditorConfigSchema = lazySchema(() => z.object({
136+
export const FieldEditorConfigSchema = lazySchema(() => strictObject({
137+
surface: 'this field editor configuration',
138+
history: OBJECT_DESIGNER_HISTORY,
139+
}, {
117140
/** Enable inline editing of field properties in the table */
118141
inlineEditing: z.boolean().default(true).describe('Enable inline editing of field properties'),
119142

@@ -157,7 +180,10 @@ export type FieldEditorConfig = z.infer<typeof FieldEditorConfigSchema>;
157180
* Relationship display configuration — controls how relationships
158181
* are visualized in the mapper and ER diagram.
159182
*/
160-
export const RelationshipDisplaySchema = lazySchema(() => z.object({
183+
export const RelationshipDisplaySchema = lazySchema(() => strictObject({
184+
surface: 'this relationship display',
185+
history: OBJECT_DESIGNER_HISTORY,
186+
}, {
161187
/** Relationship type to configure */
162188
type: z.enum(['lookup', 'master_detail', 'tree']).describe('Relationship type'),
163189

@@ -180,7 +206,10 @@ export type RelationshipDisplay = z.infer<typeof RelationshipDisplaySchema>;
180206
* Relationship Mapper configuration — controls the relationship
181207
* editing and visualization experience.
182208
*/
183-
export const RelationshipMapperConfigSchema = lazySchema(() => z.object({
209+
export const RelationshipMapperConfigSchema = lazySchema(() => strictObject({
210+
surface: 'this relationship mapp configuration',
211+
history: OBJECT_DESIGNER_HISTORY,
212+
}, {
184213
/** Enable visual relationship creation (drag from source to target) */
185214
visualCreation: z.boolean().default(true).describe('Enable drag-to-create relationships'),
186215

@@ -216,7 +245,10 @@ export type ERLayoutAlgorithm = z.infer<typeof ERLayoutAlgorithmSchema>;
216245
* Node display options — controls what information is shown
217246
* on each entity node in the ER diagram.
218247
*/
219-
export const ERNodeDisplaySchema = lazySchema(() => z.object({
248+
export const ERNodeDisplaySchema = lazySchema(() => strictObject({
249+
surface: 'this e r node display',
250+
history: OBJECT_DESIGNER_HISTORY,
251+
}, {
220252
/** Show field list within the node */
221253
showFields: z.boolean().default(true).describe('Show field list inside entity nodes'),
222254

@@ -245,7 +277,10 @@ export type ERNodeDisplay = z.infer<typeof ERNodeDisplaySchema>;
245277
* ER Diagram configuration — controls the entity-relationship
246278
* diagram rendering, interaction, and layout.
247279
*/
248-
export const ERDiagramConfigSchema = lazySchema(() => z.object({
280+
export const ERDiagramConfigSchema = lazySchema(() => strictObject({
281+
surface: 'this e r diagram configuration',
282+
history: OBJECT_DESIGNER_HISTORY,
283+
}, {
249284
/** Enable the ER diagram panel */
250285
enabled: z.boolean().default(true).describe('Enable ER diagram panel'),
251286

@@ -321,7 +356,10 @@ export const ObjectSortFieldSchema = lazySchema(() => z.enum([
321356
export type ObjectSortField = z.infer<typeof ObjectSortFieldSchema>;
322357

323358
/** Object filter criteria */
324-
export const ObjectFilterSchema = lazySchema(() => z.object({
359+
export const ObjectFilterSchema = lazySchema(() => strictObject({
360+
surface: 'this object filter',
361+
history: OBJECT_DESIGNER_HISTORY,
362+
}, {
325363
/** Filter by package/namespace */
326364
package: z.string().optional().describe('Filter by owning package'),
327365

@@ -350,7 +388,10 @@ export type ObjectFilter = z.infer<typeof ObjectFilterSchema>;
350388
* Object Manager configuration — controls the unified object list,
351389
* search, and management experience.
352390
*/
353-
export const ObjectManagerConfigSchema = lazySchema(() => z.object({
391+
export const ObjectManagerConfigSchema = lazySchema(() => strictObject({
392+
surface: 'this object manag configuration',
393+
history: OBJECT_DESIGNER_HISTORY,
394+
}, {
354395
/** Default display mode */
355396
defaultDisplayMode: ObjectListDisplayModeSchema.default('table').describe('Default list display mode'),
356397

@@ -396,7 +437,10 @@ export type ObjectManagerConfig = z.infer<typeof ObjectManagerConfigSchema>;
396437
* Preview tab configuration — defines the tabs available
397438
* when viewing a single object.
398439
*/
399-
export const ObjectPreviewTabSchema = lazySchema(() => z.object({
440+
export const ObjectPreviewTabSchema = lazySchema(() => strictObject({
441+
surface: 'this object preview tab',
442+
history: OBJECT_DESIGNER_HISTORY,
443+
}, {
400444
/** Tab key */
401445
key: z.string().describe('Tab key'),
402446

@@ -419,7 +463,10 @@ export type ObjectPreviewTab = z.infer<typeof ObjectPreviewTabSchema>;
419463
* Object Preview configuration — defines the tabs and layout
420464
* when viewing/editing a single object's metadata.
421465
*/
422-
export const ObjectPreviewConfigSchema = lazySchema(() => z.object({
466+
export const ObjectPreviewConfigSchema = lazySchema(() => strictObject({
467+
surface: 'this object preview configuration',
468+
history: OBJECT_DESIGNER_HISTORY,
469+
}, {
423470
/** Tabs to show in the object detail view */
424471
tabs: z.array(ObjectPreviewTabSchema).default([
425472
{ key: 'fields', label: 'Fields', icon: 'list', enabled: true, order: 0 },
@@ -480,7 +527,10 @@ export type ObjectDesignerDefaultView = z.infer<typeof ObjectDesignerDefaultView
480527
* });
481528
* ```
482529
*/
483-
export const ObjectDesignerConfigSchema = lazySchema(() => z.object({
530+
export const ObjectDesignerConfigSchema = lazySchema(() => strictObject({
531+
surface: 'this object design configuration',
532+
history: OBJECT_DESIGNER_HISTORY,
533+
}, {
484534
/** Default view when opening the designer */
485535
defaultView: ObjectDesignerDefaultViewSchema.default('field-editor').describe('Default view'),
486536

0 commit comments

Comments
 (0)