Skip to content

Commit c199772

Browse files
os-steveclaude
andauthored
fix(spec): title SelectOptionSchema's six row properties — clears both repeater ledger carriers (#19257)
Fixes #17506 Clause-②: no Studio renders a `type: 'repeater'` form field as a table whose column headers read `items.properties[k].title ?? k` off the JSON Schema derived from the metadata type schema. `SelectOptionSchema` (`packages/spec/src/data/field.zod.ts`) carried no `title` on any of its six row properties, so the fallback arm ran and the maker saw `label` / `value` / `description` / `color` / `default` / `visibleWhen` inside an otherwise translated panel — in every locale, English included. Titles are hard-coded English by design: `system/translation.zod.ts` states that a row property renders from `items.properties[k].title`, and `resolveMetadataFormSchemaTitles` only ever REPLACES a title that is already there, so an untitled property has no layer for a translation to overlay. Triage refused the i18n route by name; this is not routed through translations. Two halves, both here: 1. `.meta({ title })` on each of the six row properties — `Label`, `Value`, `Description`, `Color`, `Default`, `Visible When`. 2. Both of this carrier's entries deleted from the shrink-only ledger in `packages/spec/src/kernel/repeater-item-titles.test.ts`. ## One schema, two carriers — verified, not assumed `field:options` and `object:fields.options` resolve to the **same** `SelectOptionSchema` object. Measured by object identity (`===`) against the schemas the ledger itself derives from, not by structural resemblance: ``` LEG1 FieldSchema.shape.options element === SelectOptionSchema : true LEG2a getMetadataTypeSchema("field").options element === SelectOptionSchema : true object.fields is a record ; its value schema === FieldSchema : true LEG2b getMetadataTypeSchema("object").fields[*].options element === SelectOptionSchema : true SAME OBJECT both carriers : true CONTROL FormSelectOptionSchema === SelectOptionSchema (expect false) : false ``` The last line is the firing control: the probe can tell two schemas apart, so the four `true` readings are readings and not a stuck predicate. ⇒ **`packages/spec/src/data/object.zod.ts` needed no edit** and was not touched; it reaches the option shape only through its `FieldSchema` import. The declared file surface held. ## Re-derived on today's `origin/main`, not inherited from the card The ledger's own derivation, replayed over all 15 `*.form.ts` exports (22 carriers): | reading | before | after | |---|---|---| | `field:options` row properties | 6 — `label`, `value`, `description`, `color`, `default`, `visibleWhen`; all 6 untitled | all 6 titled | | `object:fields.options` row properties | the same 6, all untitled | all 6 titled | | other carriers with untitled rows | `view:columns` (14), `view:sort` (2), `view:tabs` (9) | unchanged — untouched | | carriers with zero untitled rows | 17 of 22 | 19 of 22 | The 17 already-green carriers are the lit control beside the zeros, and the three `view:*` carriers are the dark control: they stay exactly as untitled as they were, which is what an edit scoped to `SelectOptionSchema` must look like. ## The ledger was not weakened — both of its arms were made to fire Fix committed first, then mutated on disk through `scripts/ablation-replace.mjs` (anchor must hit; the write is proven by blob-hash change, never by an exit code), and restored with the restore proven by blob hash against `HEAD` plus an empty `git diff HEAD`. **Ablation 1 — delete one title.** `.meta({ title: 'Color' })` removed from `field.zod.ts`; blob `7bee63cfd9b6` → `5c4d8131a8a9`. Predicted direction: both carriers red on exactly `color`, because neither sits in the ledger any more. Observed: ``` × field:options expected [ 'color' ] to deeply equal [] × object:fields.options Tests 2 failed | 24 passed (26) restored: blob == HEAD (7bee63c) and `git diff HEAD` is empty ``` **Ablation 2 — put a paid entry back.** `'view:columns'` in `LEDGER` replaced with `'field:options'`; blob `c6579a27080a` → `2c450c99382c`. This fires **both** directions of the exact ratchet at once: ``` × field:options (ledger: still owed titles) AssertionError: field:options is fully titled now — delete its LEDGER entry in this file × view:columns AssertionError: view:columns: these row properties have no `.meta({ title })` … Tests 2 failed | 24 passed (26) restored: blob == HEAD (c6579a2) and `git diff HEAD` is empty ``` A ledger nobody has seen red on this carrier would not be evidence it is holding; it has now been seen red on this carrier, in both directions. ## Clause-② — `no`, and measured `.meta({ title })` is JSON-Schema presentation metadata and a ledger row is a test; neither moves what any schema accepts. Two independent readings agree: - `check:authorable-surface` is green with the generated `authorable-surface/` artifacts **byte-identical** — that artifact set IS "what the schema accepts", and it did not move. All 16 generated artifacts report up to date. - The pinned accept/refuse suites for this exact shape pass unchanged: `editability-boundary`, `visible-when-alias-guidance`, `form-select-option`, `evaluated-slot-population` (254 tests over 5 files). ## Changeset — owed, and why `@objectstack/spec` publishes `dist` **and** `src/**/*.zod.ts` (its `files[]`), and both carry the six new calls — `dist/data/index.mjs` reads `Color code for badges/charts").meta({ title: "Color" …`, with a nonsense title string as the negative control reading 0. Published bytes move ⇒ a `patch` changeset, not `skip-changeset`. ## Verification Run at `cff031c8db` unless stated: - `pnpm --filter @objectstack/spec test` — **500 files / 14642 tests passed**. - `pnpm --filter @objectstack/spec typecheck` — green. - `pnpm --filter @objectstack/spec check:generated` — 16 of 16 artifacts up to date. - `pnpm --filter @objectstack/spec build` then `check:api-surface` / `check:api-surface-declarations` — green (5364 declarations, text unchanged). - Consumers of the wire shape: `@objectstack/metadata-protocol` meta-types derivation tests (3 files / 35 tests) and `@objectstack/rest` `meta-types-schema-titles.test.ts` (3 tests) — green. - `scripts/pm/dispatch-gates.mjs --ran` over this diff: **81 derived, 78 run, 3 NOT MEASURED, 0 unrun**. The three are `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`, each exiting 3 (PREREQUISITE NOT MET — they need a whole-workspace build). Those are CI's farm, not a pass and not a finding. - `pnpm exec eslint --no-inline-config` over the two changed source files: 2 files linted, 0 errors, 0 warnings. The narrowing is measured, not assumed: this repo runs one `eslint.config.mjs` which never enables type-aware linting for any file (no `parserOptions.project`, no typed rules — stated and positively controlled in that file's own header), so this diff cannot move the verdict on a file it does not touch. ## Acceptance notes - **Out of scope, noted only.** `repeater-item-titles.test.ts` derives with `io: 'input'` while the server's `toJsonSchemaSafe` takes zod's default `'output'`, and the two part on `action`, whose output derivation is `{}`. The file documents this itself and calls the output-side hole a separate defect; nothing here changes it. - **Out of scope, noted only.** `view:columns`, `view:sort` and `view:tabs` stay in the ledger. They are `view.zod.ts`'s debt and that file is held by another PR. - **Observation, one reading, not isolated.** `check:api-surface-declarations` reported "0 removed, 0 added, 140 reshaped" against a `packages/spec/dist` produced by a 13-package `pnpm --filter '…^...' build` run, and reported "declaration text unchanged (5364 declarations)" against a dist produced by a standalone `pnpm --filter @objectstack/spec build` of the identical source. The standalone reading is the one quoted above. The variable was not isolated, so this is recorded as an observation rather than filed. - **Surface note.** The declared file surface was the two paths above; the third file in this PR is the changeset the publish rule requires. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d0f911 commit c199772

3 files changed

Lines changed: 29 additions & 16 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`SelectOptionSchema`'s six row properties carry a JSON Schema `title`, so Studio's property panel stops printing raw machine keys as the column headers of a field's `options` table (#17506).
6+
7+
Clause-②: no
8+
9+
Studio renders a `type: 'repeater'` form field as a table whose column headers read `items.properties[k].title ?? k` off the JSON Schema derived from the metadata type schema. `SelectOptionSchema` carried no `title` on any row property, so the fallback arm ran and the maker saw `label` / `value` / `description` / `color` / `default` / `visibleWhen` inside an otherwise translated panel — **in every locale, English included**. Titles are hard-coded English by design: `system/translation.zod.ts` states that a row property renders from `items.properties[k].title`, and `resolveMetadataFormSchemaTitles` only ever REPLACES a title that is already there, so an untitled property has no layer for a translation to overlay.
10+
11+
- **One edit clears two carriers.** `field:options` and `object:fields.options` resolve to the *same* `SelectOptionSchema` object — `FieldSchema.options` is `z.array(SelectOptionSchema)` and `object.fields` is a `z.record(..., FieldSchema)` of that same `FieldSchema` — verified by object identity (`===`) against the schemas `getMetadataTypeSchema('field')` and `getMetadataTypeSchema('object')` actually return, with `FormSelectOptionSchema` as the firing control that the probe can tell two schemas apart. Both entries are deleted from the shrink-only `repeater-item-titles` ledger in the same change; `object.zod.ts` needed no edit.
12+
- **Nothing the schema accepts or refuses moved.** `.meta({ title })` is presentation metadata: the generated `authorable-surface/` artifacts are byte-identical, and the pinned accept/refuse suites for this shape (`editability-boundary`, `visible-when-alias-guidance`, `form-select-option`, `evaluated-slot-population`) pass unchanged.
13+
- **The form-view face inherits the titles for free.** `FormSelectOptionSchema` is a shape-level Omit that reuses the same property schema instances, so the five keys it keeps arrive titled too, and its `default`-refusal is untouched.

‎packages/spec/src/data/field.zod.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,8 @@ export const SelectOptionSchema = lazySchema(() => strictObject({
335335
// no existing pointer is shadowed (`alias-integrity.test.ts`, #7889).
336336
guidanceSets: [SELECT_OPTION_EDITABILITY_GUIDANCE],
337337
}, {
338-
label: z.string().describe('Display label (human-readable, any case allowed)'),
339-
value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)'),
338+
label: z.string().describe('Display label (human-readable, any case allowed)').meta({ title: 'Label' }),
339+
value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)').meta({ title: 'Value' }),
340340
/**
341341
* Optional secondary text for the option (objectui#6153, inheriting the
342342
* objectui#6140 ruling frame — maintainer 2026-08-25: a key that is
@@ -356,9 +356,9 @@ export const SelectOptionSchema = lazySchema(() => strictObject({
356356
* spelling on its own metadata type (maintainer ruling 2026-09-02 on
357357
* objectui#6153).
358358
*/
359-
description: z.string().optional().describe('Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text.'),
360-
color: z.string().optional().describe('Color code for badges/charts'),
361-
default: z.boolean().optional().describe('Is default option'),
359+
description: z.string().optional().describe('Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text.').meta({ title: 'Description' }),
360+
color: z.string().optional().describe('Color code for badges/charts').meta({ title: 'Color' }),
361+
default: z.boolean().optional().describe('Is default option').meta({ title: 'Default' }),
362362
/**
363363
* Per-option visibility predicate (CEL) — the option is offered only when this
364364
* evaluates TRUE. Omit = always available. Evaluated against the live `record`
@@ -384,7 +384,7 @@ export const SelectOptionSchema = lazySchema(() => strictObject({
384384
* rule-validator evaluates the picked value's `visibleWhen`) — hiding it in the
385385
* dropdown alone is bypassable.
386386
*/
387-
visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`"),
387+
visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`").meta({ title: 'Visible When' }),
388388
}));
389389

390390
/**

‎packages/spec/src/kernel/repeater-item-titles.test.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,19 +84,19 @@ const FORMS: ReadonlyArray<readonly [string, unknown]> = [
8484
* Carriers still owed titles, as measured on `origin/main` at
8585
* e758131b3900eb13260f03643e295ca6d625c42b. SHRINK-ONLY — see the header.
8686
*
87-
* The remaining `view.*` / `field.*` entries were fenced out of #17232's
88-
* round by in-flight PRs on their carrier files (#17360 `view.zod.ts`,
89-
* #17477 `field.zod.ts` — `field.options` and `object.fields.options` are
90-
* the same `SelectOptionSchema`). This pin OBSERVES them without editing
91-
* them, which is why the set below is the rest of the class and not the
92-
* slice one PR could reach.
87+
* The remaining `view.*` entries were fenced out of #17232's round by an
88+
* in-flight PR on their carrier file (#17360 `view.zod.ts`). This pin
89+
* OBSERVES them without editing them, which is why the set below is the rest
90+
* of the class and not the slice one PR could reach.
9391
*
94-
* `dashboard:widgets` and `dashboard:globalFilters` were paid by #17505 and
95-
* DELETED from this set — a paid debt leaves no entry behind.
92+
* `dashboard:widgets` and `dashboard:globalFilters` were paid by #17505,
93+
* `field:options` and `object:fields.options` by #17506 — one edit for both,
94+
* because the two carriers resolve to the SAME `SelectOptionSchema` object
95+
* (`FieldSchema.options` is `z.array(SelectOptionSchema)` and `object.fields`
96+
* is a record of that same `FieldSchema`). All four are DELETED from this set
97+
* — a paid debt leaves no entry behind.
9698
*/
9799
const LEDGER: ReadonlySet<string> = new Set([
98-
'field:options',
99-
'object:fields.options',
100100
'view:columns',
101101
'view:sort',
102102
'view:tabs',

0 commit comments

Comments
 (0)