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
17 changes: 17 additions & 0 deletions .changeset/studio-strict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
---

The Studio authoring surface rejects unknown keys — plugin manifests, the flow builder, and the object designer.

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.

**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.

`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()`.

**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.

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)`.

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.
44 changes: 36 additions & 8 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,9 +509,29 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).

| File | Sites | Class | Note |
|---|---|---|---|
| `object-designer.zod.ts` | 12 | authorable (p) | Studio-written JSON — machine-authored but *our* machine; strict protects the builder itself |
| `plugin.zod.ts` | 8 | mixed (p) | |
| `flow-builder.zod.ts` | 7 | authorable (p) | independent of `FlowSchema` shapes |
| `object-designer.zod.ts` | 12 | authorable | strict as of #4001 — `defineObjectDesignerConfig` is the authoring door |
| `plugin.zod.ts` | 8 | authorable | strict as of #4001 — **was `mixed (p)`; verification found no wire half** |
| `flow-builder.zod.ts` | 7 | authorable | strict as of #4001 — `defineFlowBuilderConfig`; independent of `FlowSchema` |

**All three provisional verdicts are now verified, and one was wrong.** The
deciding evidence is the same lens the registered-type batches used: each file
exports a `define*` factory (`defineStudioPlugin`,
`defineFlowBuilderConfig`, `defineObjectDesignerConfig`) that `.parse()`s an
author-written literal. That is the authoring door, so `authorable` holds without
needing to know what `objectui` does with the result.

`plugin.zod.ts` was carried as `mixed (p)` with an empty note — a verdict with no
stated reason, which is how a provisional label survives. Reading it settles the
question: all eight shapes are contribution points on a VS Code-style plugin
manifest, every one hand-written by a plugin author. There is no wire half.

What could NOT be verified from this checkout, stated 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 and fixable, with the rename suggested — rather than
the silent narrowing it replaces. That is the trade this whole campaign makes,
and the residual risk is named here so a reader with `objectui` access can close
it rather than rediscover it.

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

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

2. `studio/` is the largest untouched authorable block — 27 sites, **0 strict**,
and all three files still carry a provisional `(p)` from the original triage.
Not blocked on field data (Studio-written JSON is our own producer, so the
downstream risk is the lowest on the board); it is simply unstarted. If the
step-1 question comes back "nothing is reporting", start here instead.
2. ~~`studio/` is the largest untouched authorable block — 27 sites, **0
strict**, and all three files still carry a provisional `(p)`.~~ **DONE
(#4001).** All 27 sites strict; the three provisional verdicts verified, and
`plugin.zod.ts`'s `mixed (p)` corrected to `authorable` — see the `studio/`
table above for the evidence and for the one thing this checkout could not
settle.

Worth noting how the verdicts were settled, because the original triage had
no method for it: each file exports a `define*` factory that parses an
author-written literal. **A `define*` factory is the authoring door** — the
same lens the registered-type batches used, and a cheaper one than reasoning
about who consumes the output. When a future triage row needs promoting out
of `(p)`, look for the factory first.

Done in the registered-types batch: `strictObject` (`shared/strict-object.ts`)
replaced the four-part wiring recipe, and `seed` + `doc` became the first two
Expand Down
51 changes: 44 additions & 7 deletions packages/spec/src/studio/flow-builder.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ import { z } from 'zod';
* Matches BPMN conventions where applicable.
*/
import { lazySchema } from '../shared/lazy-schema';
import { strictObject } from '../shared/strict-object';

/**
* Shared history for this file (#4001).
*
* Canvas configuration is visual, so a dropped key produces a canvas — just not
* the one that was configured. The author looks for the mistake in their layout,
* not in their spelling.
*/
const FLOW_BUILDER_HISTORY =
'Until #4001 closed these shapes an unknown key was dropped silently — the builder still '
+ 'rendered, without whatever the key was meant to configure.';

export const FlowNodeShapeSchema = lazySchema(() => z.enum([
'rounded_rect', // Default activity shape (assignments, CRUD, HTTP, script, subflow)
'circle', // Start / End events
Expand All @@ -57,7 +70,10 @@ export type FlowNodeShape = z.infer<typeof FlowNodeShapeSchema>;
* Maps each FlowNodeAction to its canvas rendering descriptor.
* Used by the Studio flow canvas to determine shape, icon, and default size.
*/
export const FlowNodeRenderDescriptorSchema = lazySchema(() => z.object({
export const FlowNodeRenderDescriptorSchema = lazySchema(() => strictObject({
surface: 'this flow node rend descriptor',
history: FLOW_BUILDER_HISTORY,
}, {
/** The node action type this descriptor applies to */
action: z.string().describe('FlowNodeAction value (e.g., "parallel_gateway")'),

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

Expand Down Expand Up @@ -149,7 +168,10 @@ export type FlowCanvasEdgeStyle = z.infer<typeof FlowCanvasEdgeStyleSchema>;
/**
* A sequence-flow edge on the flow canvas with visual properties.
*/
export const FlowCanvasEdgeSchema = lazySchema(() => z.object({
export const FlowCanvasEdgeSchema = lazySchema(() => strictObject({
surface: 'this flow canvas edge',
history: FLOW_BUILDER_HISTORY,
}, {
/** Reference to the flow edge id */
edgeId: z.string().describe('Corresponding FlowEdge.id'),

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

/** Optional waypoints for routing the edge around nodes */
waypoints: z.array(z.object({
waypoints: z.array(strictObject({
surface: 'this edge waypoint',
history: FLOW_BUILDER_HISTORY,
aliases: { left: 'x', top: 'y', cx: 'x', cy: 'y' },
}, {
x: z.number().describe('Waypoint X'),
y: z.number().describe('Waypoint Y'),
})).optional().describe('Manual waypoints for edge routing'),
Expand Down Expand Up @@ -207,17 +233,28 @@ export type FlowLayoutDirection = z.infer<typeof FlowLayoutDirectionSchema>;
* Flow Builder configuration — top-level config for the Studio
* automation flow canvas editor.
*/
export const FlowBuilderConfigSchema = lazySchema(() => z.object({
export const FlowBuilderConfigSchema = lazySchema(() => strictObject({
surface: 'this flow build configuration',
history: FLOW_BUILDER_HISTORY,
}, {
/** Canvas snap settings */
snap: z.object({
snap: strictObject({
surface: 'these snap settings',
history: FLOW_BUILDER_HISTORY,
aliases: { active: 'enabled', on: 'enabled', grid: 'gridSize', size: 'gridSize', step: 'gridSize', visible: 'showGrid', grid_: 'showGrid' },
}, {
enabled: z.boolean().default(true).describe('Enable snap-to-grid'),
gridSize: z.number().int().min(1).default(16).describe('Snap grid size in pixels'),
showGrid: z.boolean().default(true).describe('Show grid overlay'),
}).default({ enabled: true, gridSize: 16, showGrid: true })
.describe('Canvas snap-to-grid settings'),

/** Canvas zoom settings */
zoom: z.object({
zoom: strictObject({
surface: 'these zoom settings',
history: FLOW_BUILDER_HISTORY,
aliases: { minZoom: 'min', maxZoom: 'max', initial: 'default', defaultZoom: 'default', increment: 'step', delta: 'step' },
}, {
min: z.number().min(0.1).default(0.25).describe('Minimum zoom level'),
max: z.number().max(10).default(3).describe('Maximum zoom level'),
default: z.number().default(1).describe('Default zoom level'),
Expand Down
74 changes: 62 additions & 12 deletions packages/spec/src/studio/object-designer.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,24 @@ import { z } from 'zod';
* in the right-side property inspector.
*/
import { lazySchema } from '../shared/lazy-schema';
export const FieldPropertySectionSchema = lazySchema(() => z.object({
import { strictObject } from '../shared/strict-object';

/**
* Shared history for this file (#4001).
*
* The object designer is the surface where a human shapes the data model, so a
* dropped key here quietly narrows what the designer can express — a field group
* that never renders, a filter that never applies — while the designer itself
* looks like it is working.
*/
const OBJECT_DESIGNER_HISTORY =
'Until #4001 closed these shapes an unknown key was dropped silently — the designer still '
+ 'rendered, without whatever the key was meant to configure.';

export const FieldPropertySectionSchema = lazySchema(() => strictObject({
surface: 'this field property section',
history: OBJECT_DESIGNER_HISTORY,
}, {
/** Unique section key */
key: z.string().describe('Section key (e.g., "basics", "constraints", "security")'),

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

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

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

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

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

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

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

/** Object filter criteria */
export const ObjectFilterSchema = lazySchema(() => z.object({
export const ObjectFilterSchema = lazySchema(() => strictObject({
surface: 'this object filter',
history: OBJECT_DESIGNER_HISTORY,
}, {
/** Filter by package/namespace */
package: z.string().optional().describe('Filter by owning package'),

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

Expand Down Expand Up @@ -396,7 +437,10 @@ export type ObjectManagerConfig = z.infer<typeof ObjectManagerConfigSchema>;
* Preview tab configuration — defines the tabs available
* when viewing a single object.
*/
export const ObjectPreviewTabSchema = lazySchema(() => z.object({
export const ObjectPreviewTabSchema = lazySchema(() => strictObject({
surface: 'this object preview tab',
history: OBJECT_DESIGNER_HISTORY,
}, {
/** Tab key */
key: z.string().describe('Tab key'),

Expand All @@ -419,7 +463,10 @@ export type ObjectPreviewTab = z.infer<typeof ObjectPreviewTabSchema>;
* Object Preview configuration — defines the tabs and layout
* when viewing/editing a single object's metadata.
*/
export const ObjectPreviewConfigSchema = lazySchema(() => z.object({
export const ObjectPreviewConfigSchema = lazySchema(() => strictObject({
surface: 'this object preview configuration',
history: OBJECT_DESIGNER_HISTORY,
}, {
/** Tabs to show in the object detail view */
tabs: z.array(ObjectPreviewTabSchema).default([
{ key: 'fields', label: 'Fields', icon: 'list', enabled: true, order: 0 },
Expand Down Expand Up @@ -480,7 +527,10 @@ export type ObjectDesignerDefaultView = z.infer<typeof ObjectDesignerDefaultView
* });
* ```
*/
export const ObjectDesignerConfigSchema = lazySchema(() => z.object({
export const ObjectDesignerConfigSchema = lazySchema(() => strictObject({
surface: 'this object design configuration',
history: OBJECT_DESIGNER_HISTORY,
}, {
/** Default view when opening the designer */
defaultView: ObjectDesignerDefaultViewSchema.default('field-editor').describe('Default view'),

Expand Down
Loading
Loading