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
76 changes: 76 additions & 0 deletions .changeset/repeater-item-schema-titles-class-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
"@objectstack/spec": minor
---

feat(spec): a repeater's property-panel table has column NAMES, and an untitled item schema is now loud (#17232)

## What was wrong

Studio renders a `type: 'repeater'` form field as a table whose column headers
read `items.properties[k].title ?? k` off the JSON Schema served by
`GET /meta/types` — derived by `packages/metadata-protocol`'s `toJsonSchemaSafe`,
i.e. `z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' })`.
The bundle overlay `resolveMetadataFormSchemaTitles` (#16458 / PR #17227) only
replaces a title that is already there, so an item schema carrying no
`.meta({ title })` falls through to the raw machine key — in **every** locale,
English included. The maker read `actionUrl`, `defaultCollapsed`, `dateGranularity`
inside an otherwise fully translated panel. This is a missing authoring label in
the contract, not a translation gap.

PR #17227 titled exactly one repeater, `dashboard.header.actions`, and was scoped
by dispatch to that one. **The class stayed silent**: the next repeater to land
would reproduce the defect with every gate green.

## Measured on `origin/main` at `e758131b39`

22 repeater fields are declared across 11 `*.form.ts` files. Derived through the
platform's own predicate rather than a source regex:

- **1** was fully titled — `dashboard.header.actions`, PR #17227's instance.
- **1** has no object row shape at all — `action.locations` is an array of enum
STRINGS, so it renders no column headers and leaks no key. It is **not** a
carrier, which is why the class is **20** untitled tables today and not the 21
the card premised.
- **20** were untitled.

## What changed

**Thirteen carriers are now titled** — every row property of `action.params`,
`app.areas`, `dataset.dimensions`, `dataset.measures`, `flow.nodes`,
`flow.edges`, `flow.variables`, `page.variables`, `page.regions`,
`page.interfaceConfig.sort`, `report.order`, `report.blocks` and
`skill.triggerConditions` carries a `.meta({ title })`. `page.interfaceConfig.sort`
is titled through the shared `SortItemSchema` it composes.

**The silence is closed.** `packages/spec/src/kernel/repeater-item-titles.test.ts`
enumerates every repeater declared across every `*.form.ts` in the package,
derives each row schema through `z.toJSONSchema`, and requires a title on every
authorable row property. Carriers still owed one sit in an EXACT, shrink-only
ledger: a repeater absent from the ledger must be fully titled, and a ledger
entry whose debt has been paid must be deleted. A new repeater is therefore red
on the day it lands, and the ledger can only shrink.

Two exclusions the pin makes deliberately, each with its own control:

- a `retiredKey()` tombstone is a parse-time refusal, not an authorable column
(`flow.nodes[].outputSchema`);
- a scalar-item repeater has no row properties to name (`action.locations`),
and is pinned by name so an object-shaped one cannot land there silently.

## What is still owed, and why

Seven carriers remain on the ledger because their item schemas live in files held
by other in-flight PRs at the time of writing — `dashboard.widgets` and
`dashboard.globalFilters` (`ui/dashboard.zod.ts`), `view.columns` / `view.sort` /
`view.tabs` (`ui/view.zod.ts`), and `field.options` + `object.fields.options`
(the one `SelectOptionSchema` in `data/field.zod.ts`). The pin OBSERVES them
without editing them, so the ledger states the whole class rather than the slice
one PR could reach.

Localisation is additive and unchanged by this round. `.meta({ title })` is the
English authoring layer by contract — `translation.zod.ts` states it in those
words — and a bundle's `metadataForms.<type>.fields.<repeater>.<property>.label`
overlays it per locale. No form file here enumerates repeater children, so
`os i18n extract` emits no new catalog keys and no catalog moves. Until those
leaves are authored, a non-English panel shows the English title rather than the
machine key — strictly better than today, and the localisation layer is still owed.
6 changes: 3 additions & 3 deletions packages/spec/src/ai/skill.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,13 @@ function checkSkillTriggerConditionValueShape(
*/
export const SkillTriggerConditionSchema = lazySchema(() => z.object({
/** Condition field (e.g. 'objectName', 'userRole', 'channel') */
field: z.string().describe('Context field to evaluate'),
field: z.string().describe('Context field to evaluate').meta({ title: 'Context Field' }),

/** Comparison operator */
operator: z.enum(['eq', 'neq', 'in', 'not_in', 'contains']).describe('Comparison operator'),
operator: z.enum(['eq', 'neq', 'in', 'not_in', 'contains']).describe('Comparison operator').meta({ title: 'Operator' }),

/** Expected value(s) — an array for `in`/`not_in`, a string for `eq`/`neq` */
value: z.union([z.string(), z.array(z.string())]).describe('Expected value or values'),
value: z.union([z.string(), z.array(z.string())]).describe('Expected value or values').meta({ title: 'Value' }),
}).superRefine(checkSkillTriggerConditionValueShape));

export type SkillTriggerCondition = z.input<typeof SkillTriggerConditionSchema>;
Expand Down
48 changes: 26 additions & 22 deletions packages/spec/src/automation/flow.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,16 +158,17 @@ export const FlowVariableSchema = lazySchema(() => strictObject(
'mis-declared input/output contract shipped without a diagnostic.',
},
{
name: z.string().describe('Variable name'),
type: z.string().describe('Data type (text, number, boolean, object, list)'),
isInput: z.boolean().default(false).describe('Is input parameter'),
isOutput: z.boolean().default(false).describe('Is output parameter'),
name: z.string().describe('Variable name').meta({ title: 'Name' }),
type: z.string().describe('Data type (text, number, boolean, object, list)').meta({ title: 'Type' }),
isInput: z.boolean().default(false).describe('Is input parameter').meta({ title: 'Input' }),
isOutput: z.boolean().default(false).describe('Is output parameter').meta({ title: 'Output' }),
defaultValue: z.unknown().optional()
.describe(
'Value bound at run start when no parameter supplies one — this is what makes a ' +
'declared variable always bound. An explicitly supplied param wins, including ' +
'`false` and `null`; the boundary is `params[name] !== undefined`.',
),
)
.meta({ title: 'Default Value' }),
}));

/**
Expand Down Expand Up @@ -301,15 +302,15 @@ function flowNodeObject() { return strictObject(
'config shipped as a step that quietly ignored it.',
},
{
id: z.string().describe('Node unique ID'),
id: z.string().describe('Node unique ID').meta({ title: 'ID' }),
type: z.string().min(1).describe(
'Action type — a built-in FlowNodeAction id or a plugin-registered node type. ' +
'Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum.',
),
label: z.string().describe('Node label'),
).meta({ title: 'Node Type' }),
label: z.string().describe('Node label').meta({ title: 'Label' }),

/** Node Configuration Options (Specific to type) */
config: z.record(z.string(), z.unknown()).optional().describe('Node configuration'),
config: z.record(z.string(), z.unknown()).optional().describe('Node configuration').meta({ title: 'Configuration' }),

/**
* Connector Action Configuration
Expand Down Expand Up @@ -347,7 +348,7 @@ function flowNodeObject() { return strictObject(
actionId: z.string().describe('Action key declared by the connector'),
input: z.record(z.string(), z.unknown()).optional().describe('Mapped inputs for the action'),
},
).optional(),
).optional().meta({ title: 'Connector Action' }),

/**
* UI Position (for the canvas).
Expand All @@ -367,10 +368,11 @@ function flowNodeObject() { return strictObject(
'ever been written.',
},
{ x: z.number(), y: z.number() },
).optional(),
).optional().meta({ title: 'Canvas Position' }),

/** Node-level execution timeout */
timeoutMs: z.number().int().min(0).optional().describe('Maximum execution time for this node in milliseconds'),
timeoutMs: z.number().int().min(0).optional().describe('Maximum execution time for this node in milliseconds')
.meta({ title: 'Timeout (ms)' }),

/** Node input schema declaration for Studio form generation and runtime validation */
inputSchema: z.record(z.string(), strictObject(
Expand Down Expand Up @@ -398,7 +400,7 @@ function flowNodeObject() { return strictObject(
required: z.boolean().default(false).describe('Whether the parameter is required'),
description: z.string().optional().describe('Parameter description'),
},
)).optional().describe('Input parameter schema for this node'),
)).optional().describe('Input parameter schema for this node').meta({ title: 'Input Schema' }),

// `outputSchema` REMOVED (#3896 audit close-out): declared, never validated —
// no engine path checked node outputs against it (ledger: dead).
Expand Down Expand Up @@ -491,7 +493,7 @@ function flowNodeObject() { return strictObject(
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing '
+ 'sources; apply them by hand.',
),
}).optional().describe('Configuration for wait node event resumption'),
}).optional().describe('Configuration for wait node event resumption').meta({ title: 'Wait Event' }),

/**
* Boundary Event Configuration (for 'boundary_event' nodes)
Expand Down Expand Up @@ -534,7 +536,7 @@ function flowNodeObject() { return strictObject(
timerDuration: z.string().optional().describe('ISO 8601 duration for timer boundary events'),
/** Signal name — only for signal boundary events */
signalName: z.string().optional().describe('Named signal to catch'),
}).optional().describe('Configuration for boundary events attached to host nodes'),
}).optional().describe('Configuration for boundary events attached to host nodes').meta({ title: 'Boundary Event' }),
}); }

/**
Expand All @@ -559,9 +561,9 @@ export const FlowEdgeSchema = lazySchema(() => strictObject(
'predicate or endpoint the author wrote was quietly ignored.',
},
{
id: z.string().describe('Edge unique ID'),
source: z.string().describe('Source Node ID'),
target: z.string().describe('Target Node ID'),
id: z.string().describe('Edge unique ID').meta({ title: 'ID' }),
source: z.string().describe('Source Node ID').meta({ title: 'From Node' }),
target: z.string().describe('Target Node ID').meta({ title: 'To Node' }),

/**
* Condition for this path (only for decision/branch nodes).
Expand All @@ -583,16 +585,17 @@ export const FlowEdgeSchema = lazySchema(() => strictObject(
+ 'envelope carrying a non-blank `source` — an `ast`-only envelope, and a `source` that is blank after '
+ 'trimming, are refused at authoring because the engine evaluates `source` alone and would otherwise answer '
+ 'a silent `false`.',
),
).meta({ title: 'Condition' }),

type: z.enum(['default', 'fault', 'conditional', 'back'])
.default('default')
.describe(
'Connection type: default (normal flow), fault (error path), conditional (expression-guarded), '
+ 'or back (ADR-0044 declared back-edge — traversed normally at run time, but excluded from DAG '
+ 'cycle validation so a revise/rework loop can re-enter an earlier node)',
),
label: z.string().optional().describe('Label on the connector'),
)
.meta({ title: 'Connection Type' }),
label: z.string().optional().describe('Label on the connector').meta({ title: 'Label' }),

/**
* Default Sequence Flow marker (BPMN Default Flow semantics).
Expand All @@ -614,7 +617,8 @@ export const FlowEdgeSchema = lazySchema(() => strictObject(
.describe(
'BPMN default flow: traverse this edge only when no sibling conditional edge of the same '
+ 'source node matched. Mutually exclusive with `condition`; at most one per source node.',
),
)
.meta({ title: 'Default Path' }),
}));

/**
Expand Down
Loading
Loading