Skip to content

Commit d2badf7

Browse files
os-billclaude
andauthored
feat(spec): a repeater's property-panel table has column names, and an untitled item schema is now loud (#17500)
* test(spec): pin every repeater row property to carry a JSON Schema title 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`. An item schema with no `.meta({ title })` falls through to the raw machine key in every locale, English included. The class was silent: one repeater was titled and the next one to land would reproduce the defect with every gate green. This pin enumerates every repeater declared across every `*.form.ts` in the package, derives each row schema through the platform's own `z.toJSONSchema` predicate, and requires a title on every authorable row property — with an exact, shrink-only ledger for the carriers still owed one. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * feat(spec): author English titles on thirteen repeater item schemas 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` now carries a `.meta({ title })`, so Studio's property-panel table shows an authoring name instead of the raw machine key. `page.interfaceConfig.sort` is titled through the shared `SortItemSchema` in `shared/enums.zod.ts`, which is the item schema it composes. The pin's title read is corrected in the same commit: a `.meta({ title })` on a schema zod hoists into `$defs` is emitted as a SIBLING of the `$ref` (`{ title, $ref }`), which is where the console reads it from — following the ref first reported a titled property as untitled. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * chore(spec): changeset for the repeater item-title class guard Also drops an unused `depth` parameter from the pin's `deref` helper, which `check:test-typecheck` reads as TS6133 inside its checked zone. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f8e5790 commit d2badf7

10 files changed

Lines changed: 523 additions & 83 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): a repeater's property-panel table has column NAMES, and an untitled item schema is now loud (#17232)
6+
7+
## What was wrong
8+
9+
Studio renders a `type: 'repeater'` form field as a table whose column headers
10+
read `items.properties[k].title ?? k` off the JSON Schema served by
11+
`GET /meta/types` — derived by `packages/metadata-protocol`'s `toJsonSchemaSafe`,
12+
i.e. `z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' })`.
13+
The bundle overlay `resolveMetadataFormSchemaTitles` (#16458 / PR #17227) only
14+
replaces a title that is already there, so an item schema carrying no
15+
`.meta({ title })` falls through to the raw machine key — in **every** locale,
16+
English included. The maker read `actionUrl`, `defaultCollapsed`, `dateGranularity`
17+
inside an otherwise fully translated panel. This is a missing authoring label in
18+
the contract, not a translation gap.
19+
20+
PR #17227 titled exactly one repeater, `dashboard.header.actions`, and was scoped
21+
by dispatch to that one. **The class stayed silent**: the next repeater to land
22+
would reproduce the defect with every gate green.
23+
24+
## Measured on `origin/main` at `e758131b39`
25+
26+
22 repeater fields are declared across 11 `*.form.ts` files. Derived through the
27+
platform's own predicate rather than a source regex:
28+
29+
- **1** was fully titled — `dashboard.header.actions`, PR #17227's instance.
30+
- **1** has no object row shape at all — `action.locations` is an array of enum
31+
STRINGS, so it renders no column headers and leaks no key. It is **not** a
32+
carrier, which is why the class is **20** untitled tables today and not the 21
33+
the card premised.
34+
- **20** were untitled.
35+
36+
## What changed
37+
38+
**Thirteen carriers are now titled** — every row property of `action.params`,
39+
`app.areas`, `dataset.dimensions`, `dataset.measures`, `flow.nodes`,
40+
`flow.edges`, `flow.variables`, `page.variables`, `page.regions`,
41+
`page.interfaceConfig.sort`, `report.order`, `report.blocks` and
42+
`skill.triggerConditions` carries a `.meta({ title })`. `page.interfaceConfig.sort`
43+
is titled through the shared `SortItemSchema` it composes.
44+
45+
**The silence is closed.** `packages/spec/src/kernel/repeater-item-titles.test.ts`
46+
enumerates every repeater declared across every `*.form.ts` in the package,
47+
derives each row schema through `z.toJSONSchema`, and requires a title on every
48+
authorable row property. Carriers still owed one sit in an EXACT, shrink-only
49+
ledger: a repeater absent from the ledger must be fully titled, and a ledger
50+
entry whose debt has been paid must be deleted. A new repeater is therefore red
51+
on the day it lands, and the ledger can only shrink.
52+
53+
Two exclusions the pin makes deliberately, each with its own control:
54+
55+
- a `retiredKey()` tombstone is a parse-time refusal, not an authorable column
56+
(`flow.nodes[].outputSchema`);
57+
- a scalar-item repeater has no row properties to name (`action.locations`),
58+
and is pinned by name so an object-shaped one cannot land there silently.
59+
60+
## What is still owed, and why
61+
62+
Seven carriers remain on the ledger because their item schemas live in files held
63+
by other in-flight PRs at the time of writing — `dashboard.widgets` and
64+
`dashboard.globalFilters` (`ui/dashboard.zod.ts`), `view.columns` / `view.sort` /
65+
`view.tabs` (`ui/view.zod.ts`), and `field.options` + `object.fields.options`
66+
(the one `SelectOptionSchema` in `data/field.zod.ts`). The pin OBSERVES them
67+
without editing them, so the ledger states the whole class rather than the slice
68+
one PR could reach.
69+
70+
Localisation is additive and unchanged by this round. `.meta({ title })` is the
71+
English authoring layer by contract — `translation.zod.ts` states it in those
72+
words — and a bundle's `metadataForms.<type>.fields.<repeater>.<property>.label`
73+
overlays it per locale. No form file here enumerates repeater children, so
74+
`os i18n extract` emits no new catalog keys and no catalog moves. Until those
75+
leaves are authored, a non-English panel shows the English title rather than the
76+
machine key — strictly better than today, and the localisation layer is still owed.

packages/spec/src/ai/skill.zod.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,13 +192,13 @@ function checkSkillTriggerConditionValueShape(
192192
*/
193193
export const SkillTriggerConditionSchema = lazySchema(() => z.object({
194194
/** Condition field (e.g. 'objectName', 'userRole', 'channel') */
195-
field: z.string().describe('Context field to evaluate'),
195+
field: z.string().describe('Context field to evaluate').meta({ title: 'Context Field' }),
196196

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

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

204204
export type SkillTriggerCondition = z.input<typeof SkillTriggerConditionSchema>;

packages/spec/src/automation/flow.zod.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -158,16 +158,17 @@ export const FlowVariableSchema = lazySchema(() => strictObject(
158158
'mis-declared input/output contract shipped without a diagnostic.',
159159
},
160160
{
161-
name: z.string().describe('Variable name'),
162-
type: z.string().describe('Data type (text, number, boolean, object, list)'),
163-
isInput: z.boolean().default(false).describe('Is input parameter'),
164-
isOutput: z.boolean().default(false).describe('Is output parameter'),
161+
name: z.string().describe('Variable name').meta({ title: 'Name' }),
162+
type: z.string().describe('Data type (text, number, boolean, object, list)').meta({ title: 'Type' }),
163+
isInput: z.boolean().default(false).describe('Is input parameter').meta({ title: 'Input' }),
164+
isOutput: z.boolean().default(false).describe('Is output parameter').meta({ title: 'Output' }),
165165
defaultValue: z.unknown().optional()
166166
.describe(
167167
'Value bound at run start when no parameter supplies one — this is what makes a ' +
168168
'declared variable always bound. An explicitly supplied param wins, including ' +
169169
'`false` and `null`; the boundary is `params[name] !== undefined`.',
170-
),
170+
)
171+
.meta({ title: 'Default Value' }),
171172
}));
172173

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

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

314315
/**
315316
* Connector Action Configuration
@@ -347,7 +348,7 @@ function flowNodeObject() { return strictObject(
347348
actionId: z.string().describe('Action key declared by the connector'),
348349
input: z.record(z.string(), z.unknown()).optional().describe('Mapped inputs for the action'),
349350
},
350-
).optional(),
351+
).optional().meta({ title: 'Connector Action' }),
351352

352353
/**
353354
* UI Position (for the canvas).
@@ -367,10 +368,11 @@ function flowNodeObject() { return strictObject(
367368
'ever been written.',
368369
},
369370
{ x: z.number(), y: z.number() },
370-
).optional(),
371+
).optional().meta({ title: 'Canvas Position' }),
371372

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

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

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

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

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

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

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

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

620624
/**

0 commit comments

Comments
 (0)