Skip to content

fix(spec): title SelectOptionSchema's six row properties — clears both repeater ledger carriers - #19257

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-17506-select-option-row-titles
Sep 20, 2026
Merged

os-steve merged 2 commits into
mainfrom
claude/issue-17506-select-option-row-titles

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

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 7bee63cfd9b65c4d8131a8a9. 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 (7bee63cfd9b6) and `git diff HEAD` is empty

Ablation 2 — put a paid entry back. 'view:columns' in LEDGER replaced with 'field:options'; blob c6579a27080a2c450c99382c. 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 (c6579a27080a) 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 test500 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

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 of its six row properties, so the fallback arm ran and the property
panel printed the raw machine keys — in every locale, English included.

Add `.meta({ title })` to `label`, `value`, `description`, `color`,
`default` and `visibleWhen`, and delete this carrier's two entries from
the shrink-only `repeater-item-titles` ledger. One edit clears both:
`field:options` and `object:fields.options` resolve to the SAME
`SelectOptionSchema` object — `FieldSchema.options` is
`z.array(SelectOptionSchema)` and `object.fields` is a record of that same
`FieldSchema` — verified by object identity, not assumed.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
`@objectstack/spec` publishes `dist/` and `src/**/*.zod.ts`, and both now
carry the six `.meta({ title })` calls, so published bytes move and a
changeset is owed.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json cb005e06aeae13bcedbea9b3827c89de3c6833a4packageMentionDocs.

Which tree this was computed on

This run read content/docs from fda6e9dddf70f4aca69d4f7dea84320ab0a73a0b — the merge of head cff031c8db3ca3cb133df93d4d2704a333951689 into base cb005e06aeae13bcedbea9b3827c89de3c6833a4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fda6e9dddf70f4aca69d4f7dea84320ab0a73a0b && git checkout fda6e9dddf70f4aca69d4f7dea84320ab0a73a0b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cb005e06aeae13bcedbea9b3827c89de3c6833a4 cff031c8db3ca3cb133df93d4d2704a333951689 && git checkout -B drift-repro cb005e06aeae13bcedbea9b3827c89de3c6833a4 && git merge --no-ff cff031c8db3ca3cb133df93d4d2704a333951689

node scripts/docs-audit/affected-docs.mjs --json cb005e06aeae13bcedbea9b3827c89de3c6833a4

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cff031c8db3ca3cb133df93d4d2704a333951689

Reviewed on a detached worktree at the head above (merge-base with origin/main at 7056ca50d1, origin/main read at 0f42d36ffc), against primary sources only; nothing below is taken from the PR body or the dispatch report. Every exit code was captured before any pipe; every zero reading names its radius and a target outside it that fired.

① Derived judgments

1. The ledger still bites — both arms re-fired, predicate unchanged. packages/spec/src/kernel/repeater-item-titles.test.ts at head: exit 0, 26/26, with field:options and object:fields.options judged as titled carriers and view:columns / view:sort / view:tabs still marked owed. The diff to that file is exactly one hunk (@@ -84,19 +84,19 @@: the ledger comment plus the two deleted entries); the file above it (lines 1–83) and below it (from const RETIRED_PREFIX to EOF, 253 lines) are byte-identical to the merge-base, so the derivation and both assertions are the landed #17232 predicate, not a broadened, special-cased or relaxed one.

  • Ablation A (my own leg, a different property from the implementer's): .meta({ title: 'Default' }) removed from field.zod.ts (blob 7bee63cfd9b6dc276fb8705b). vitest exit 1, 2 failed / 24 passed: field:options and object:fields.options both red with expected [ 'default' ] to deeply equal [] — exactly that property, both carriers. Restored: blob 7bee63cfd9b6 == HEAD, git status --porcelain empty.
  • Ablation B: 'field:options' re-added to LEDGER with view:columns left in place (blob c6579a27080a5666fb7a192f). vitest exit 1, 1 failed / 25 passed: field:options (ledger: still owed titles) red with field:options is fully titled now — delete its LEDGER entry in this file: expected 0 to be greater than 0. Restored: blob c6579a27080a == HEAD, porcelain empty.

2. The two-carrier identity claim — re-derived, holds, and the probe can say no. A probe walking zod internals (_zod.def: lazy getter, wrapper innerType, object shape, record valueType, array element) through the schemas getMetadataTypeSchema actually returns:

  • getMetadataTypeSchema('field') === FieldSchema: true; FieldSchema.options element === SelectOptionSchema: true (trail object › optional › array).
  • getMetadataTypeSchema('object') === ObjectSchema: true; ObjectSchema.fields record valueType === FieldSchema: true (trail object › record); its options element === SelectOptionSchema: true; the two carriers' elements === each other: true.
  • Controls: FormSelectOptionSchema === SelectOptionSchema false; SelectOptionSchema.describe('clone') === SelectOptionSchema false while the clone's JSON Schema properties are byte-equal to the original's — so === is strictly finer than structure and the four trues are readings. Identical readings under OS_EAGER_SCHEMAS=1, i.e. with lazySchema's memoised Proxy bypassed: the identity is of the declaration, not of the proxy.
  • Through the platform's own predicate (z.toJSONSchema over getMetadataTypeSchema, property node read before its $ref target, as the ledger and the console read it), both carriers read label: Label, value: Value, description: Description, color: Color, default: Default, visibleWhen: Visible When, untitled [], identical title map on both — under io: 'input' (the ledger) AND io: 'output' (the server's toJsonSchemaSafe default). ⇒ object.zod.ts needed no edit; delisting object:fields.options is earned, not assumed.
  • Joint-risk with the PR that holds object.zod.ts (spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147 wraps fields in a z.preprocess guard): measured on zod 4.4.3, a preprocess-wrapped record derives identically to a plain one under both io modes, so that landing does not make object:fields.options unresolvable to this ledger. Recorded, no action.

3. Scope and Clause-②: no. Three files moved (git diff --stat against the merge-base: changeset +13, field.zod.ts +6/−6, ledger test +10/−10); object.zod.ts untouched. Nothing moves an accept set:

  • check:authorable-surface at head: exit 0 (1543 schemas, tracked tree unchanged, porcelain empty afterwards). Firing control: one authorable key zzz_reviewer_control added to SelectOptionSchema → exit 1 with the ratchet's "unrecorded key" text; restored by blob.
  • Feed test: 19 authored inputs (accept and refuse shapes) over SelectOptionSchema, FormSelectOptionSchema, FieldSchema and ObjectSchema — success bit, parsed data and every issue's path/code/message — serialised at head and again with field.zod.ts restored from the merge-base (blob 8bf5f106193e): byte-identical (diff exit 0, 0 lines). Firing control: color: z.string()z.number() moved 4 of the 19 cases. Head reading: 6 accepted / 13 refused.
  • The four pinned suites for this shape (editability-boundary, evaluated-slot-population, visible-when-alias-guidance, form-select-option): exit 0, 4 files / 228 tests.
  • eslint over the two changed source files: exit 0; control (an in-repo file with a parse error): exit 1.
  • Generated artifacts, on a fresh standalone build in the worktree: pnpm build (packages/spec, DTS on) exit 0, check-dts-emitted 34/34; git status --porcelain empty afterwards, so everything the build regenerates (json-schema/, authorable-surface/, authorable-defaults/) is byte-identical to the committed tree. pnpm check:generated exit 0 — all 16 generated artifacts up to date, check:authorable-surface and check:api-surface among them. pnpm check:api-surface-declarations exit 0: declaration text unchanged (17 entry points, 5364 declarations). Firing control for that gate: one declaration's text altered in the committed api-surface-declarations/ai.txt (source: string;source: number;, blob 82bc01fe59c88c349f9baee8) → exit 1, 0 removed, 0 added, 1 reshaped, attributed to KnowledgeRefreshPolicySchema (const); restored by blob == HEAD, porcelain empty.

4. The six titles are right, not merely present. Each read against the property it labels: label "Display label" → Label; value "Stored value (lowercase machine identifier)" → Value; description "Optional secondary/help text" → Description; color "Color code for badges/charts" → Color; default "Is default option" (a boolean flag, not a value — sibling defaultValue keys are titled Default Value, and this key is the flag) → Default; visibleWhen "Per-option visibility predicate (CEL)" → Visible When. Precedent in this package: Label (7 files), Description (4), Value (skill.zod.ts), Visible When (action.zod.ts); Color and Default are first uses and match their descriptions. No title mislabels its key. The form-view face inherits them: FormSelectOptionSchema is a shape-level Omit and the probe reads all five kept property schemas === the object face's, with default absent.

② Semver level

patch is the right level. @objectstack/spec ships dist and src/**/*.zod.ts in files[], so the six .meta() calls change published bytes — a changeset is owed and skip-changeset would be wrong. Nothing exported changes name, kind or declared type (.meta() returns this), no authorable key is added or removed, and no accept set moves (§3), so minor is not owed and Clause-②: no is the correct declaration. The changeset gates run locally at head all exit 0: check-empty-changeset, check-adr-0087-registration (no declared-breaking changeset, so no disposition marker is owed), check-changeset-fixed, check-changeset-no-major.

③ Boundary flags

The unfiled observation — two api-surface-declarations readings

The implementer surfaced two readings of check:api-surface-declarations on the same source: 0 removed, 0 added, 140 reshaped against a dist from a 13-package pnpm --filter closure build, and declaration text unchanged (5364 declarations) against a standalone pnpm --filter @objectstack/spec build. Judged against primary sources:

  • The gate is regenerate-and-compare — the fresh dist's declaration text against the committed shards, behind a dist-freshness guard — so both readings were of a fresh dist of identical source. The committed shards do not move in this PR, and this source diff cannot move declaration text (.meta() returns this; no exported type changes shape).
  • Two builds in two environments agree with the committed shards: my standalone build (§3 above) and CI's Type Check · consumer gates job, which builds the whole closure through turbo (68 packages) and then runs this very gate — green on this head, completed at 2026-09-20T06:39:47Z. The 140-reshaped reading is the odd one out and is reproduced by neither.
  • ⇒ No reading the PR quotes is unsound, and the standalone reading is the right one to quote: it is the instrument the repo prescribes (build the package, then the gate), and it is corroborated by the closure-built reading of record. The semver level does not turn on it either way.
  • What it leaves open is real and is not this PR's: a local closure build produced different dts text for 140 declarations of unchanged source, and a sibling round saw the same shape tonight. The gate prints per-export attribution (~ name (kind)), so whoever isolates it should diff the two runs' attributed lists rather than re-run until one wins. It stays an observation here and is ⛔ not widened into this PR.

Findings

BLOCKING: none. Nothing in the diff, the ledger, the identity claim, the accept sets or the changeset level fails its check, and I am not manufacturing balance to say otherwise.

Noted (prose only; no code change asked, none blocks):

  1. The PR body, the changeset and the ledger's inherited spec: every repeater item schema except dashboard header.actions still has no JSON Schema title, so 21 property-panel tables render machine keys in every locale #17232 header all say resolveMetadataFormSchemaTitles "only ever REPLACES a title that is already there". Read at packages/spec/src/system/i18n-resolver.ts:3187-3191, setSchemaTitleAtPath sets title on whatever node a bundle path addresses, present or not. The consequence the sentence is used for still holds — the English default has no source other than .meta({ title }) — but the mechanism is mis-described, and the changeset carries that sentence into CHANGELOG.md. Acceptable as shipped; if anyone corrects it, the route is a dedicated docs/changeset-only PR, not a rider.
  2. The PR quotes 254 tests over 5 files for the pinned suites; at head the four names given resolve to 4 files / 228 tests. Not material to any judgment above.

Reviewed at 2026-09-20T06:51Z. CI readings quoted here were read at 2026-09-20T06:45:27Z (the latest job completion in that read).

Implemented-by: claude/issue-17506-select-option-row-titles
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/s tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] Untitled repeater row properties: SelectOptionSchema (6) shared by field.options and object.fields.options — fence dropped, dispatchable now

2 participants