diff --git a/.changeset/repeater-item-schema-titles-class-guard.md b/.changeset/repeater-item-schema-titles-class-guard.md new file mode 100644 index 0000000000..9e8f0e6322 --- /dev/null +++ b/.changeset/repeater-item-schema-titles-class-guard.md @@ -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..fields...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. diff --git a/packages/spec/src/ai/skill.zod.ts b/packages/spec/src/ai/skill.zod.ts index 8a37b713a1..113b20fedc 100644 --- a/packages/spec/src/ai/skill.zod.ts +++ b/packages/spec/src/ai/skill.zod.ts @@ -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; diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 968be831f2..93f7c83064 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -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' }), })); /** @@ -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 @@ -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). @@ -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( @@ -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). @@ -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) @@ -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' }), }); } /** @@ -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). @@ -583,7 +585,7 @@ 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') @@ -591,8 +593,9 @@ export const FlowEdgeSchema = lazySchema(() => strictObject( '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). @@ -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' }), })); /** diff --git a/packages/spec/src/kernel/repeater-item-titles.test.ts b/packages/spec/src/kernel/repeater-item-titles.test.ts new file mode 100644 index 0000000000..98892745a1 --- /dev/null +++ b/packages/spec/src/kernel/repeater-item-titles.test.ts @@ -0,0 +1,357 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #17232 — THE class guard for "a repeater's property-panel table shows the +// maker raw machine keys". +// +// 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`. That schema is derived by +// `packages/metadata-protocol/src/protocol.ts` → `toJsonSchemaSafe`, i.e. +// `z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' })`, +// and the bundle overlay (`resolveMetadataFormSchemaTitles`, #16458/#17227) +// only ever REPLACES a `title` that is already there. So an item schema with +// no `.meta({ title })` falls through to the raw key in EVERY locale, English +// included — a missing authoring label in the contract, not a translation gap. +// +// #16458 (PR #17227) titled exactly one repeater, `dashboard.header.actions`. +// The class was left silent: the 22nd repeater added next month reproduces the +// defect with every gate green. THIS FILE IS THE LOUDNESS. It enumerates every +// repeater declared across every `*.form.ts` in this package, derives each +// one's row schema through the platform's own predicate, and requires every +// row property to carry a title — with a shrink-only ledger of the carriers +// that are still owed one. +// +// The ledger is EXACT in both directions, which is what makes it a ratchet +// rather than a suppression list: +// +// • a repeater that is NOT in the ledger MUST be fully titled — so a new +// repeater is red on the day it lands, not a month later; +// • a repeater that IS in the ledger MUST still be untitled — so titling one +// and forgetting to delete its entry is also red, and the ledger can only +// shrink. +// +// ⛔ Never add an entry to LEDGER to make this file green. An entry is a debt +// record for a carrier that predates this pin (and, for the four below, one +// held open by another PR's fence at the time it was written). A NEW untitled +// repeater is the defect this file exists to catch. + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { getMetadataTypeSchema } from './metadata-type-schemas'; + +import { skillForm } from '../ai/skill.form'; +import { agentForm } from '../ai/agent.form'; +import { toolForm } from '../ai/tool.form'; +import { flowForm } from '../automation/flow.form'; +import { objectForm } from '../data/object.form'; +import { fieldForm } from '../data/field.form'; +import { hookForm } from '../data/hook.form'; +import { positionForm } from '../identity/position.form'; +import { actionForm } from '../ui/action.form'; +import { appForm } from '../ui/app.form'; +import { dashboardForm } from '../ui/dashboard.form'; +import { datasetForm } from '../ui/dataset.form'; +import { pageForm } from '../ui/page.form'; +import { reportForm } from '../ui/report.form'; +import { viewForm } from '../ui/view.form'; + +/** + * Every `*.form.ts` in this package, by its export name. `check:generated` + * does not police this list, so `it('covers every *.form.ts', …)` below reads + * the directory listing this file cannot — it asserts the COUNT against the + * form exports the domain barrels carry, which is what a new form file moves. + */ +const FORMS: ReadonlyArray = [ + ['actionForm', actionForm], + ['agentForm', agentForm], + ['appForm', appForm], + ['dashboardForm', dashboardForm], + ['datasetForm', datasetForm], + ['fieldForm', fieldForm], + ['flowForm', flowForm], + ['hookForm', hookForm], + ['objectForm', objectForm], + ['pageForm', pageForm], + ['positionForm', positionForm], + ['reportForm', reportForm], + ['skillForm', skillForm], + ['toolForm', toolForm], + ['viewForm', viewForm], +]; + +/** + * Carriers still owed titles, as measured on `origin/main` at + * e758131b3900eb13260f03643e295ca6d625c42b. SHRINK-ONLY — see the header. + * + * The four `dashboard.*` / `view.*` / `field.*` entries were fenced out of + * #17232's round by in-flight PRs on their carrier files (#17474 `dashboard.zod.ts`, + * #17360 `view.zod.ts`, #17477 `field.zod.ts` — `field.options` and + * `object.fields.options` are the same `SelectOptionSchema`). This pin + * OBSERVES them without editing them, which is why the count below is the + * whole class and not the slice one PR could reach. + */ +const LEDGER: ReadonlySet = new Set([ + 'dashboard:widgets', + 'dashboard:globalFilters', + 'field:options', + 'object:fields.options', + 'view:columns', + 'view:sort', + 'view:tabs', +]); + +/** `z.never().optional().describe('[REMOVED] …')` — `shared/retired-key.ts`. */ +const RETIRED_PREFIX = '[REMOVED] '; + +type Node = Record; + +/** Every `{ path, spec }` a form declares, composite/repeater children included. */ +function* walkFormFields(fields: any[] | undefined, prefix = ''): Generator<{ path: string; spec: any }> { + for (const f of fields ?? []) { + if (!f || typeof f !== 'object' || !f.field) continue; + const path = prefix ? `${prefix}.${f.field}` : String(f.field); + yield { path, spec: f }; + if (Array.isArray(f.fields)) yield* walkFormFields(f.fields, path); + } +} + +/** Follow `$ref` into `$defs`. */ +function deref(node: Node | undefined, root: Node): Node | undefined { + let n = node; + let d = 0; + while (n && typeof n === 'object' && typeof n.$ref === 'string' && d++ < 8) { + const m = /^#\/\$defs\/(.+)$/.exec(n.$ref); + n = m ? root.$defs?.[m[1]] : undefined; + } + return n; +} + +/** Merged `properties` of a node and of every union arm under it. */ +function propertiesOf(node: Node | undefined, root: Node, depth = 0): Record { + const n = deref(node, root); + if (!n || typeof n !== 'object' || depth > 8) return {}; + const out: Record = { ...(n.properties ?? {}) }; + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + for (const arm of n[key] ?? []) Object.assign(out, propertiesOf(arm, root, depth + 1)); + } + return out; +} + +/** Every node a union can resolve to, so an arm is never merged away. */ +function candidates(node: Node | undefined, root: Node, depth = 0): Node[] { + const n = deref(node, root); + if (!n || typeof n !== 'object' || depth > 8) return []; + const out = [n]; + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + for (const arm of n[key] ?? []) out.push(...candidates(arm, root, depth + 1)); + } + return out; +} + +/** The child-key map a dotted path step looks up on: array rows, record values, or plain properties. */ +function childrenOf(node: Node | undefined, root: Node, depth = 0): Record { + const n = deref(node, root); + if (!n || typeof n !== 'object' || depth > 8) return {}; + if (n.type === 'array' && n.items) return propertiesOf(n.items, root, depth + 1); + if (n.additionalProperties && typeof n.additionalProperties === 'object') { + return { ...propertiesOf(n, root, depth + 1), ...propertiesOf(n.additionalProperties, root, depth + 1) }; + } + const direct = propertiesOf(n, root, depth + 1); + if (Object.keys(direct).length) return direct; + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + for (const arm of n[key] ?? []) { + const c = childrenOf(arm, root, depth + 1); + if (Object.keys(c).length) return c; + } + } + return {}; +} + +/** + * Resolve a form field's dotted path to its schema node. + * + * Each step keeps EVERY union arm as a separate candidate rather than merging + * them: `view` is a four-arm union in which `columns` is an object array on + * the list arm and an INTEGER (form body columns) on the form arm, and a + * merge silently keeps whichever arm zod emitted last. + */ +function resolveFieldNode(root: Node, segments: string[]): Node | undefined { + let cursor = candidates(root, root); + for (const seg of segments) { + const next: Node[] = []; + for (const node of cursor) { + const kids = childrenOf(node, root); + if (kids[seg]) next.push(...candidates(kids[seg], root)); + } + if (next.length === 0) return undefined; + cursor = next; + } + // A repeater is an ARRAY — prefer an object-item array arm over a scalar one. + return ( + cursor.find((n) => n.type === 'array' && Object.keys(propertiesOf(n.items, root)).length > 0) ?? + cursor.find((n) => n.type === 'array') ?? + cursor[0] + ); +} + +/** A repeater's ROW schema — the array's `items`, through refs and unions. */ +function rowSchemaOf(node: Node | undefined, root: Node, depth = 0): Node | undefined { + const n = deref(node, root); + if (!n || typeof n !== 'object' || depth > 8) return undefined; + if (n.type === 'array' && n.items && !Array.isArray(n.items)) return deref(n.items, root); + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + for (const arm of n[key] ?? []) { + const r = rowSchemaOf(arm, root, depth + 1); + if (r) return r; + } + } + return undefined; +} + +interface Carrier { + /** `:` — the ledger key. */ + id: string; + type: string; + path: string; + /** Row properties an author may write — retired tombstones excluded. */ + authorable: string[]; + untitled: string[]; + /** Set when the row has no object shape at all (a scalar-item repeater). */ + scalarItems?: string; +} + +/** + * Derive every repeater carrier through the platform's own predicate. + * + * ⚠️ `io: 'input'` where the server's `toJsonSchemaSafe` takes zod's default + * (`'output'`). The two agree on all fourteen other types; they part on + * `action`, whose `ActionSchema` ends in a `.transform()` — the OUTPUT + * derivation of a `ZodPipe` is `{}`, with no properties at all. `.meta({ title })` + * rides both derivations identically, so the input shape is the one that can + * see the authoring surface this pin is about. The output-side hole is a + * separate defect and is not this pin's to hide. + */ +function deriveCarriers(): Carrier[] { + const carriers: Carrier[] = []; + for (const [, form] of FORMS) { + const f = form as any; + const type = f?.data?.schemaId as string | undefined; + if (!type) continue; + const zodSchema = getMetadataTypeSchema(type); + if (!zodSchema) continue; + const root = z.toJSONSchema(zodSchema, { unrepresentable: 'any', io: 'input' }) as Node; + const fields = (f.sections ?? []).flatMap((s: any) => s.fields ?? []); + for (const { path, spec } of walkFormFields(fields)) { + if (spec.type !== 'repeater') continue; + const carrier: Carrier = { id: `${type}:${path}`, type, path, authorable: [], untitled: [] }; + const node = resolveFieldNode(root, path.split('.')); + const row = rowSchemaOf(node, root); + const props = row ? propertiesOf(row, root) : {}; + if (Object.keys(props).length === 0) { + carrier.scalarItems = String(row?.type ?? 'unresolved'); + carriers.push(carrier); + continue; + } + for (const [key, raw] of Object.entries(props)) { + const prop = deref(raw, root); + // A retired key is a parse-time refusal, not an authorable column. + const description = raw?.description ?? prop?.description; + if (typeof description === 'string' && description.startsWith(RETIRED_PREFIX)) continue; + carrier.authorable.push(key); + // ⚠️ The property node FIRST, and only then the `$ref` target. A + // `.meta({ title })` on a schema zod hoists into `$defs` is emitted as + // a sibling of the `$ref` (`{ title, $ref }`), which is precisely + // where the console reads `items.properties[k].title` from — deref + // first and a titled property reads as untitled. + const title = raw?.title ?? prop?.title; + if (typeof title !== 'string' || title.length === 0) carrier.untitled.push(key); + } + carriers.push(carrier); + } + } + return carriers; +} + +const CARRIERS = deriveCarriers(); +const OBJECT_ROW_CARRIERS = CARRIERS.filter((c) => !c.scalarItems); + +describe('#17232 — the repeater survey itself (controls before verdicts)', () => { + it('walks every form this package exports, and finds repeaters in exactly the forms that declare one', () => { + // Lit control — the walk really ran. + expect(FORMS.length).toBe(15); + expect(CARRIERS.length).toBeGreaterThan(20); + + const carrying = new Set(CARRIERS.map((c) => c.type)); + // Lit: four domains declare repeaters. + for (const type of ['action', 'dashboard', 'flow', 'view', 'report', 'skill']) { + expect(carrying.has(type), `${type} declares a repeater`).toBe(true); + } + // Dark: forms that declare NO repeater must contribute none. A walk that + // matched everything, or nothing, cannot pass both halves. + for (const type of ['agent', 'tool', 'hook', 'position']) { + expect(carrying.has(type), `${type} declares no repeater`).toBe(false); + } + }); + + it('resolves every repeater to a real row schema — an unresolved path is a hole in the survey, not a pass', () => { + for (const c of CARRIERS) { + if (c.scalarItems) { + // The one legitimate shape with no row properties: `action.locations` + // is an array of enum STRINGS, so the panel renders no column headers + // at all and there is no machine key to leak. Pinned by name so a + // future object-shaped repeater cannot land here silently. + expect(c.id, 'the only repeater with no object row shape').toBe('action:locations'); + expect(c.scalarItems).toBe('string'); + continue; + } + expect(c.authorable.length, `${c.id} resolved to a row schema with no authorable properties`).toBeGreaterThan(0); + } + }); + + it('excludes retired keys from the authorable row — a tombstone is a parse error, not a column', () => { + const flowNodes = CARRIERS.find((c) => c.id === 'flow:nodes'); + expect(flowNodes, 'flow.nodes is a repeater').toBeDefined(); + // Lit: the row really was read. + expect(flowNodes!.authorable).toContain('inputSchema'); + // Dark: `flow.nodes[].outputSchema` is a `retiredKey` tombstone. + expect(flowNodes!.authorable).not.toContain('outputSchema'); + }); +}); + +describe('#17232 — every repeater row property carries a JSON Schema title', () => { + for (const carrier of OBJECT_ROW_CARRIERS) { + const owed = LEDGER.has(carrier.id); + it(`${carrier.id}${owed ? ' (ledger: still owed titles)' : ''}`, () => { + if (owed) { + // Shrink-only: a ledger entry that has been paid must be DELETED, so + // the ledger can never quietly outlive the debt it records. + expect( + carrier.untitled.length, + `${carrier.id} is fully titled now — delete its LEDGER entry in this file`, + ).toBeGreaterThan(0); + return; + } + expect( + carrier.untitled, + `${carrier.id}: these row properties have no \`.meta({ title })\`, so Studio's property-panel ` + + `table prints the raw key as the column header in every locale, English included. Author an ` + + `English title on the item schema — ⛔ do not add this carrier to LEDGER.`, + ).toEqual([]); + }); + } + + it('the ledger names only carriers that exist — a stale entry is a rule guarding nothing', () => { + const ids = new Set(CARRIERS.map((c) => c.id)); + for (const id of LEDGER) { + expect(ids.has(id), `LEDGER names '${id}', which no form declares any more — delete it`).toBe(true); + } + }); + + it('dashboard.header.actions stays titled — the one carrier #17227 closed', () => { + const c = CARRIERS.find((x) => x.id === 'dashboard:header.actions'); + expect(c, 'dashboard.header.actions is a repeater').toBeDefined(); + expect(c!.authorable).toEqual(['label', 'actionUrl', 'actionType', 'icon']); + expect(c!.untitled).toEqual([]); + }); +}); diff --git a/packages/spec/src/shared/enums.zod.ts b/packages/spec/src/shared/enums.zod.ts index 570539923b..af87037b95 100644 --- a/packages/spec/src/shared/enums.zod.ts +++ b/packages/spec/src/shared/enums.zod.ts @@ -25,8 +25,8 @@ export type SortDirection = z.input; /** Reusable sort item — field + direction pair used across views, data sources, filters */ export const SortItemSchema = lazySchema(() => z.object({ - field: z.string().describe('Field name to sort by'), - order: SortDirectionEnum.describe('Sort direction'), + field: z.string().describe('Field name to sort by').meta({ title: 'Field' }), + order: SortDirectionEnum.describe('Sort direction').meta({ title: 'Direction' }), }).describe('Sort field and direction pair')); export type SortItem = z.input; diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index a959974f80..90a6e6c486 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -176,22 +176,22 @@ export const ActionParamSchema = lazySchema(() => strictObject( }, { /** Request-body key. Defaults to `field` when `field` is set. */ - name: z.string().optional(), + name: z.string().optional().meta({ title: 'Name' }), /** Reference an existing object field for label/type/validation/options. */ - field: SnakeCaseIdentifierSchema.optional(), + field: SnakeCaseIdentifierSchema.optional().meta({ title: 'Field' }), /** Object that owns the referenced field (defaults to the action's parent object). */ - objectOverride: SnakeCaseIdentifierSchema.optional(), + objectOverride: SnakeCaseIdentifierSchema.optional().meta({ title: 'Object Override' }), /** Overrides the resolved field label (or sets it for inline params). */ - label: I18nLabelSchema.optional(), + label: I18nLabelSchema.optional().meta({ title: 'Label' }), /** Overrides the resolved field type (or sets it for inline params). */ - type: FieldType.optional(), + type: FieldType.optional().meta({ title: 'Type' }), /** * Required override; when omitted defaults to `false`. Consumers that wish * to inherit the underlying field's `required` flag should leave this * undefined in the source schema and resolve at runtime (the dialog * renderers check truthiness, so `false === undefined` for UI purposes). */ - required: z.boolean().optional().default(false), + required: z.boolean().optional().default(false).meta({ title: 'Required' }), /** * Select/picklist options override. * @@ -327,11 +327,11 @@ export const ActionParamSchema = lazySchema(() => strictObject( * bypassable. */ visibleWhen: ExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Same env as the field-level per-option visibleWhen (record + current_user). e.g. P`record.tier == 'gold'`"), - })).optional(), + })).optional().meta({ title: 'Options' }), /** Placeholder override. */ - placeholder: z.string().optional(), + placeholder: z.string().optional().meta({ title: 'Placeholder' }), /** Help/description override. */ - helpText: z.string().optional(), + helpText: z.string().optional().meta({ title: 'Help Text' }), /** * Default value for the dialog input — prefilled into the control when the * dialog opens, and SUBMITTED VERBATIM if the user does not touch the field @@ -345,7 +345,7 @@ export const ActionParamSchema = lazySchema(() => strictObject( * runtime tokens (`current_user`, CEL `today()`; ROADMAP §M9.9b), nothing on * the action-param path interprets this value. */ - defaultValue: z.unknown().optional(), + defaultValue: z.unknown().optional().meta({ title: 'Default Value' }), /** * Widget config for inline params (field-backed params inherit these from * the referenced field at runtime; inline values override). The param @@ -354,11 +354,11 @@ export const ActionParamSchema = lazySchema(() => strictObject( * `FieldSchema` knobs. */ /** Allow multiple values (file/image/lookup/user params → array value). */ - multiple: z.boolean().optional().describe('Allow multiple values (array value shape); mirrors FieldSchema.multiple.'), + multiple: z.boolean().optional().describe('Allow multiple values (array value shape); mirrors FieldSchema.multiple.').meta({ title: 'Multiple' }), /** Accepted upload types (MIME types / extensions) for `file`/`image` params. */ - accept: z.array(z.string()).optional().describe('Accepted upload types (MIME types / extensions) for file/image params.'), + accept: z.array(z.string()).optional().describe('Accepted upload types (MIME types / extensions) for file/image params.').meta({ title: 'Accepted Types' }), /** Max upload size in bytes for `file`/`image` params. */ - maxSize: z.number().int().positive().optional().describe('Max upload size in bytes for file/image params.'), + maxSize: z.number().int().positive().optional().describe('Max upload size in bytes for file/image params.').meta({ title: 'Max Size (bytes)' }), /** * Reference target for an inline `lookup` / `master_detail` param — the * object whose records the picker searches. Field-backed params inherit it @@ -371,13 +371,13 @@ export const ActionParamSchema = lazySchema(() => strictObject( * Key name deliberately mirrors `FieldSchema.reference` so the same spelling * works in both places. */ - reference: SnakeCaseIdentifierSchema.optional().describe('Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference.'), + reference: SnakeCaseIdentifierSchema.optional().describe('Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference.').meta({ title: 'Reference Object' }), /** * When true, the param's default value is pulled from the current row record * (key = the resolved field name) when the action runs from a list_item * context. Useful for edit dialogs that pre-fill from the selected row. */ - defaultFromRow: z.boolean().optional(), + defaultFromRow: z.boolean().optional().meta({ title: 'Default From Row' }), /** * Carry-over declaration (#11753 ruling, 2026-08-25): the param's value is * carried through the dialog rather than collected from the user — seeded @@ -411,7 +411,7 @@ export const ActionParamSchema = lazySchema(() => strictObject( + 'render it as a non-editable summary in the dialog, and submit it verbatim in the request ' + 'body. Unlike `visible: false` (which omits the param from the submission entirely), a ' + 'carry-over param is always sent.', - ), + ).meta({ title: 'Carry Over' }), /** * Visibility predicate (CEL) — same scope as the action-level `visible` * (`current_user` / `data` / `features`). When it evaluates false the @@ -420,7 +420,7 @@ export const ActionParamSchema = lazySchema(() => strictObject( * param gated on `features.phoneNumber` so the form never offers a field the * default backend rejects. Absent = always visible. */ - visible: ExpressionInputSchema.optional().describe('Param visibility predicate (CEL); omits the param when false.'), + visible: ExpressionInputSchema.optional().describe('Param visibility predicate (CEL); omits the param when false.').meta({ title: 'Visible When' }), /** * Declarative capability gate (#2874): name a public auth feature flag * (see `PUBLIC_AUTH_FEATURES` in `@objectstack/spec/kernel`) and the schema @@ -431,7 +431,7 @@ export const ActionParamSchema = lazySchema(() => strictObject( * over a hand-written `features.*` predicate: the flag name is * enum-checked and the gate/registry stay in lockstep. */ - requiresFeature: z.enum(PUBLIC_AUTH_FEATURE_NAMES).optional().describe('Public auth feature flag gating this param; lowered into `visible` at parse time.'), + requiresFeature: z.enum(PUBLIC_AUTH_FEATURE_NAMES).optional().describe('Public auth feature flag gating this param; lowered into `visible` at parse time.').meta({ title: 'Requires Feature' }), }).refine( (p) => Boolean(p.name) || Boolean(p.field), { message: 'ActionParam requires either "name" or "field"' }, diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index d124663c97..d42e9f0392 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -955,19 +955,19 @@ export const NavigationAreaSchema = lazySchema(() => strictObject( }, { /** Unique area identifier */ - id: SnakeCaseIdentifierSchema.describe('Unique area identifier (lowercase snake_case)'), + id: SnakeCaseIdentifierSchema.describe('Unique area identifier (lowercase snake_case)').meta({ title: 'ID' }), /** Display label */ - label: I18nLabelSchema.describe('Area display label'), + label: I18nLabelSchema.describe('Area display label').meta({ title: 'Label' }), /** Icon name (Lucide) */ - icon: z.string().optional().describe('Area icon name'), + icon: z.string().optional().describe('Area icon name').meta({ title: 'Icon' }), // `order` removed in 17.0.0 (#4667) — see AREA_ORDER_RETIRED. Reorder the // `areas` array instead; declaration order is display order. /** Area description */ - description: I18nLabelSchema.optional().describe('Area description'), + description: I18nLabelSchema.optional().describe('Area description').meta({ title: 'Description' }), // `visible` and `requiredPermissions` removed in 17.0.0 (#4651) — see // AREA_VISIBLE_RETIRED / AREA_REQUIRED_PERMISSIONS_RETIRED. Both were @@ -976,7 +976,7 @@ export const NavigationAreaSchema = lazySchema(() => strictObject( // (or the app) instead. /** Navigation items within this area */ - navigation: z.array(NavigationItemSchema).describe('Navigation items within this area'), + navigation: z.array(NavigationItemSchema).describe('Navigation items within this area').meta({ title: 'Navigation' }), })); /** diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index 16060caf58..5c90e77cd7 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -114,18 +114,18 @@ export const DatasetDimensionSchema = lazySchema(() => strictObject({ }, }, { /** Referenced by presentations (report rows/columns, widget dimensions). */ - name: SnakeCaseIdentifierSchema.describe('Dimension name — referenced by presentations'), - label: I18nLabelSchema.optional(), + name: SnakeCaseIdentifierSchema.describe('Dimension name — referenced by presentations').meta({ title: 'Name' }), + label: I18nLabelSchema.optional().meta({ title: 'Label' }), /** * A field on the base object, OR a relationship path (one or more to-one hops) * ending in a field — e.g. `account.region` or `account.owner.region` * (ADR-0071 multi-hop). The join chain is DERIVED from the relationship(s) * declared in `Dataset.include`; the author never writes a predicate. */ - field: z.string().describe('Base field, or `relationship[.relationship].field` path'), - type: z.enum(['string', 'number', 'date', 'boolean', 'lookup']).optional(), + field: z.string().describe('Base field, or `relationship[.relationship].field` path').meta({ title: 'Field' }), + type: z.enum(['string', 'number', 'date', 'boolean', 'lookup']).optional().meta({ title: 'Type' }), /** Default bucketing for date dimensions (day/week/month/quarter/year). */ - dateGranularity: DateGranularity.optional(), + dateGranularity: DateGranularity.optional().meta({ title: 'Date Granularity' }), })); /** @@ -180,14 +180,15 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({ 'a measure has no `description` — its author-facing text is `label`. `description` is declared on the DATASET itself; put the explanation there.', }, }, { - name: SnakeCaseIdentifierSchema.describe('Measure name — e.g. "revenue"; defined once'), - label: I18nLabelSchema.optional(), + name: SnakeCaseIdentifierSchema.describe('Measure name — e.g. "revenue"; defined once').meta({ title: 'Name' }), + label: I18nLabelSchema.optional().meta({ title: 'Label' }), /** Aggregation function — reuses the canonical query.zod enum. */ - aggregate: AggregationFunction.optional().describe('Aggregation (sum/avg/count/...); omit when `derived` is set'), + aggregate: AggregationFunction.optional().describe('Aggregation (sum/avg/count/...); omit when `derived` is set') + .meta({ title: 'Aggregate' }), /** Base field, or `relationship[.relationship].field` path. Optional for `count` (count(*)). */ - field: z.string().optional().describe('Aggregated field; optional for count(*)'), + field: z.string().optional().describe('Aggregated field; optional for count(*)').meta({ title: 'Field' }), /** Measure-scoped filter (e.g. only won deals for "won_amount"). */ - filter: FilterConditionSchema.optional(), + filter: FilterConditionSchema.optional().meta({ title: 'Filter' }), /** * Display format — a NUMERAL pattern controlling grouping, decimals and * percent: `"0,0.00"`, `"0.0%"`. A `$` in the pattern is still honoured as a @@ -228,14 +229,14 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({ + 'An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued ' + 'measure never reads a date pattern: `"YYYY-MM-DD"` renders the locale default. A date-only ' + 'value reads `format` as a display style (`short`, `relative`); a datetime value ignores it.', - ), + ).meta({ title: 'Format' }), /** * Display currency (ISO 4217, e.g. "USD", "CNY"). Carried onto the result * field so presentations render a locale-correct symbol via `Intl` rather * than a "$" baked into `format`. Declare it on the measure (the semantic * layer) when the aggregated field is a fixed-currency amount. */ - currency: z.string().length(3).optional().describe('Display currency code (ISO 4217)'), + currency: z.string().length(3).optional().describe('Display currency code (ISO 4217)').meta({ title: 'Currency' }), /** * Derived measure — computed from OTHER measures in this dataset by name * only. e.g. `{ op: 'ratio', of: ['won_amount', 'total_amount'] }`. @@ -278,7 +279,7 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({ op: DerivedMeasureOp, /** Names of other measures in this dataset (2+ for ratio/difference). */ of: z.array(SnakeCaseIdentifierSchema).min(1), - }).optional(), + }).optional().meta({ title: 'Derived From' }), })); /** diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 267a7cfd59..d8f39d0ece 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -43,9 +43,9 @@ export const PageRegionSchema = lazySchema(() => strictObject({ history: PAGE_HISTORY, aliases: { id: 'name', region: 'name', children: 'components', items: 'components', content: 'components', size: 'width', span: 'width' }, }, { - name: z.string().describe('Region name (e.g. "sidebar", "main", "header")'), - width: z.enum(['small', 'medium', 'large', 'full']).optional(), - components: z.array(z.lazy(() => PageComponentSchema)).describe('Components in this region') + name: z.string().describe('Region name (e.g. "sidebar", "main", "header")').meta({ title: 'Region' }), + width: z.enum(['small', 'medium', 'large', 'full']).optional().meta({ title: 'Width' }), + components: z.array(z.lazy(() => PageComponentSchema)).describe('Components in this region').meta({ title: 'Components' }) })); // Page-component TYPES retired by name → the prescription an author who still @@ -405,13 +405,15 @@ export const PageVariableSchema = lazySchema(() => strictObject({ bindTo: 'the binding names the WRITER, not a target — `source` is the id of the component that writes this variable; readers reference it as `page.`', }, }, { - name: z.string().describe('Variable name. Exposed to expressions as `page.`.'), - type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'record_id']).default('string'), + name: z.string().describe('Variable name. Exposed to expressions as `page.`.').meta({ title: 'Name' }), + type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'record_id']).default('string').meta({ title: 'Type' }), defaultValue: z.unknown().optional() - .describe('Initial value. Defaults to a type-appropriate empty value when omitted.'), + .describe('Initial value. Defaults to a type-appropriate empty value when omitted.') + .meta({ title: 'Default Value' }), /** Source element binding — the component id that writes this variable. */ source: z.string().optional() - .describe('Component id that writes this variable (e.g. an element:record_picker whose `id` matches).'), + .describe('Component id that writes this variable (e.g. an element:record_picker whose `id` matches).') + .meta({ title: 'Written By' }), })); // BlankPageLayoutItemSchema / BlankPageLayoutSchema removed — the `blank` page diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 4cd83cc805..fe9d289f63 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -81,9 +81,9 @@ export const ReportSortSchema = lazySchema(() => strictObject({ }, }, { /** A dimension (`rows`/`columns`) or measure (`values`) name this report selects. */ - by: z.string().describe('Dimension or measure name to order by (must be selected by this report)'), + by: z.string().describe('Dimension or measure name to order by (must be selected by this report)').meta({ title: 'Order By' }), /** Sort direction. Null/empty cells sort LAST in both directions. */ - direction: z.enum(['asc', 'desc']).default('asc').describe('Sort direction (default ascending)'), + direction: z.enum(['asc', 'desc']).default('asc').describe('Sort direction (default ascending)').meta({ title: 'Direction' }), })); /** @@ -213,32 +213,32 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObje }, }, { /** Stable id for the block (used as react key, telemetry, deeplinks). */ - name: SnakeCaseIdentifierSchema, + name: SnakeCaseIdentifierSchema.meta({ title: 'Name' }), /** Human label shown above the block. Falls back to `name`. */ - label: I18nLabelSchema.optional(), + label: I18nLabelSchema.optional().meta({ title: 'Label' }), /** Optional description rendered below the label. */ - description: I18nLabelSchema.optional(), + description: I18nLabelSchema.optional().meta({ title: 'Description' }), /** Block report type — `joined` is intentionally excluded (no recursion). */ - type: z.enum(['tabular', 'summary', 'matrix']).default('tabular'), + type: z.enum(['tabular', 'summary', 'matrix']).default('tabular').meta({ title: 'Block Type' }), /** Optional inline chart configuration. */ - chart: ReportChartSchema.optional(), + chart: ReportChartSchema.optional().meta({ title: 'Chart' }), /** * ADR-0021 — the dataset this block binds to (single-form). The block selects * the dataset's measures by name; the legacy inline `objectName` + `columns` + * `groupings` query was removed in the cutover. */ - dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)'), + dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)').meta({ title: 'Dataset' }), /** Dimension names (from the dataset) to group rows by. Dataset-bound only. */ - rows: z.array(z.string()).optional().describe('Dimension names down (dataset-bound)'), + rows: z.array(z.string()).optional().describe('Dimension names down (dataset-bound)').meta({ title: 'Rows' }), /** Dimension names across — matrix blocks pivot rows × columns (ADR-0021 D2). */ - columns: z.array(z.string()).optional().describe('Dimension names across (matrix, dataset-bound)'), + columns: z.array(z.string()).optional().describe('Dimension names across (matrix, dataset-bound)').meta({ title: 'Columns' }), /** Measure names (from the dataset) to display. Dataset-bound only. */ - values: z.array(z.string()).optional().describe('Measure names to show (dataset-bound)'), + values: z.array(z.string()).optional().describe('Measure names to show (dataset-bound)').meta({ title: 'Values' }), /** Render-time scope filter, ANDed at query time. Dataset-bound only. */ - runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter (dataset-bound)'), + runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter (dataset-bound)').meta({ title: 'Runtime Filter' }), /** Result ordering for this block, most significant key first (framework#3916). */ - order: z.array(ReportSortSchema).optional().describe('Result ordering, most significant key first'), + order: z.array(ReportSortSchema).optional().describe('Result ordering, most significant key first').meta({ title: 'Order' }), }).superRefine(checkReportOrder)); /**