diff --git a/.changeset/studio-strict.md b/.changeset/studio-strict.md new file mode 100644 index 0000000000..d03f1d7acb --- /dev/null +++ b/.changeset/studio-strict.md @@ -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. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 6df2d82d0b..0976563cba 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -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) @@ -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 diff --git a/packages/spec/src/studio/flow-builder.zod.ts b/packages/spec/src/studio/flow-builder.zod.ts index b0057b6615..bf3fda8d75 100644 --- a/packages/spec/src/studio/flow-builder.zod.ts +++ b/packages/spec/src/studio/flow-builder.zod.ts @@ -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 @@ -57,7 +70,10 @@ export type FlowNodeShape = z.infer; * 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")'), @@ -98,7 +114,10 @@ export type FlowNodeRenderDescriptor = z.infer 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'), @@ -149,7 +168,10 @@ export type FlowCanvasEdgeStyle = z.infer; /** * 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'), @@ -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'), @@ -207,9 +233,16 @@ export type FlowLayoutDirection = z.infer; * 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'), @@ -217,7 +250,11 @@ export const FlowBuilderConfigSchema = lazySchema(() => z.object({ .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'), diff --git a/packages/spec/src/studio/object-designer.zod.ts b/packages/spec/src/studio/object-designer.zod.ts index e2dfed6804..45ca360da8 100644 --- a/packages/spec/src/studio/object-designer.zod.ts +++ b/packages/spec/src/studio/object-designer.zod.ts @@ -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")'), @@ -91,7 +108,10 @@ export type FieldPropertySection = z.infer; * 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'), @@ -113,7 +133,10 @@ export type FieldGroup = z.infer; /** * 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'), @@ -157,7 +180,10 @@ export type FieldEditorConfig = z.infer; * 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'), @@ -180,7 +206,10 @@ export type RelationshipDisplay = z.infer; * 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'), @@ -216,7 +245,10 @@ export type ERLayoutAlgorithm = z.infer; * 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'), @@ -245,7 +277,10 @@ export type ERNodeDisplay = z.infer; * 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'), @@ -321,7 +356,10 @@ export const ObjectSortFieldSchema = lazySchema(() => z.enum([ export type ObjectSortField = z.infer; /** 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'), @@ -350,7 +388,10 @@ export type ObjectFilter = z.infer; * 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'), @@ -396,7 +437,10 @@ export type ObjectManagerConfig = z.infer; * 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'), @@ -419,7 +463,10 @@ export type ObjectPreviewTab = z.infer; * 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 }, @@ -480,7 +527,10 @@ export type ObjectDesignerDefaultView = z.infer 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'), diff --git a/packages/spec/src/studio/plugin.zod.ts b/packages/spec/src/studio/plugin.zod.ts index 8deb15d249..bf7c955493 100644 --- a/packages/spec/src/studio/plugin.zod.ts +++ b/packages/spec/src/studio/plugin.zod.ts @@ -60,6 +60,21 @@ import { z } from 'zod'; /** Supported view modes for metadata viewers */ import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; + +/** + * Shared history for this file (#4001). + * + * A Studio plugin manifest is the `package.json` of an extension — declarative, + * hand-written, and parsed once by `defineStudioPlugin`. A dropped key here does + * not fail the plugin: it loads, activates, and simply contributes less than its + * author declared. A viewer that never appears in the switcher looks like a + * registration bug, not a spelling one. + */ +const STUDIO_PLUGIN_HISTORY = + 'Until #4001 closed these shapes an unknown key was dropped silently — the plugin still ' + + 'loaded and activated, contributing less than its manifest declared.'; + export const ViewModeSchema = lazySchema(() => z.enum(['preview', 'design', 'code', 'data', 'history'])); export type ViewMode = z.infer; @@ -69,7 +84,10 @@ export type ViewMode = z.infer; * Declares a metadata viewer/designer component. * The runtime component is registered imperatively during plugin activation. */ -export const MetadataViewerContributionSchema = lazySchema(() => z.object({ +export const MetadataViewerContributionSchema = lazySchema(() => strictObject({ + surface: 'this metadata view contribution', + history: STUDIO_PLUGIN_HISTORY, +}, { /** Unique viewer ID (namespaced: `pluginId.viewerId`) */ id: z.string().describe('Unique viewer identifier'), @@ -94,7 +112,10 @@ export type MetadataViewerContribution = z.infer z.object({ +export const SidebarGroupContributionSchema = lazySchema(() => strictObject({ + surface: 'this sidebar group contribution', + history: STUDIO_PLUGIN_HISTORY, +}, { /** Unique group key */ key: z.string().describe('Unique group key'), @@ -122,7 +143,10 @@ export const ActionLocationSchema = lazySchema(() => z.enum(['toolbar', 'context * Declares an action that can be triggered on metadata items. * The handler is registered imperatively during activation. */ -export const ActionContributionSchema = lazySchema(() => z.object({ +export const ActionContributionSchema = lazySchema(() => strictObject({ + surface: 'this action contribution', + history: STUDIO_PLUGIN_HISTORY, +}, { /** Unique action ID */ id: z.string().describe('Unique action identifier'), @@ -147,7 +171,10 @@ export type ActionContribution = z.infer; * Declares an icon and label for a metadata type. * Used by the sidebar and breadcrumbs. */ -export const MetadataIconContributionSchema = lazySchema(() => z.object({ +export const MetadataIconContributionSchema = lazySchema(() => strictObject({ + surface: 'this metadata icon contribution', + history: STUDIO_PLUGIN_HISTORY, +}, { /** Metadata type this icon represents */ metadataType: z.string().describe('Metadata type'), @@ -168,7 +195,10 @@ export const PanelLocationSchema = lazySchema(() => z.enum(['bottom', 'right', ' /** * Declares an auxiliary panel (like VS Code's Terminal, Problems, Output panels). */ -export const PanelContributionSchema = lazySchema(() => z.object({ +export const PanelContributionSchema = lazySchema(() => strictObject({ + surface: 'this panel contribution', + history: STUDIO_PLUGIN_HISTORY, +}, { /** Unique panel ID */ id: z.string().describe('Unique panel identifier'), @@ -190,7 +220,10 @@ export type PanelContribution = z.infer; * Declares a command that can be invoked from the command palette * or programmatically by other plugins. */ -export const CommandContributionSchema = lazySchema(() => z.object({ +export const CommandContributionSchema = lazySchema(() => strictObject({ + surface: 'this command contribution', + history: STUDIO_PLUGIN_HISTORY, +}, { /** Unique command ID (namespaced: `pluginId.commandName`) */ id: z.string().describe('Unique command identifier'), @@ -212,7 +245,10 @@ export type CommandContribution = z.infer; * All contribution points a Studio plugin can declare. * Analogous to VS Code's `contributes` section in `package.json`. */ -export const StudioPluginContributionsSchema = lazySchema(() => z.object({ +export const StudioPluginContributionsSchema = lazySchema(() => strictObject({ + surface: 'this studio plugin contributions', + history: STUDIO_PLUGIN_HISTORY, +}, { /** Metadata viewer/designer components */ metadataViewers: z.array(MetadataViewerContributionSchema).default([]), @@ -257,7 +293,34 @@ export const ActivationEventSchema = lazySchema(() => z.string().describe('Activ * All contribution points are declared here; runtime components * are registered imperatively during the `activate()` call. */ -export const StudioPluginManifestSchema = lazySchema(() => z.object({ +export const StudioPluginManifestSchema = lazySchema(() => strictObject({ + surface: 'this studio plugin manifest', + history: STUDIO_PLUGIN_HISTORY, + // This file 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 below is a word that is correct over there. + aliases: { + displayName: 'name', title: 'name', + publisher: 'author', vendor: 'author', + contributions: 'contributes', contribute: 'contributes', + activation: 'activationEvents', events: 'activationEvents', onActivate: 'activationEvents', + }, + guidance: { + // VS Code manifest keys with no counterpart here. `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()`. + main: + 'there is no entry-point key — a Studio plugin declares its contributions here and registers ' + + 'the runtime components imperatively in its `activate()` call. Nothing is loaded from a path.', + engines: 'there is no engine-compatibility field; a plugin carries `version` and nothing gates it', + categories: 'there is no category taxonomy — UI grouping comes from `contributes.sidebarGroups`', + keywords: 'there is no keyword index for Studio plugins', + repository: 'manifest metadata is limited to `id` / `name` / `version` / `description` / `author`', + icon: 'the manifest carries no icon — icons are Lucide names on the CONTRIBUTION (`contributes.metadataIcons`, or an action / panel / command `icon`)', + dependencies: 'Studio plugins declare no dependency graph; order activation with `activationEvents` instead', + }, +}, { /** * Unique plugin ID using reverse-domain notation. * @example "objectstack.object-designer"