Skip to content

fix(console): settings selects follow the specifier's valueDomain declaration - #6568

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-3719-settings-valuedomain-combobox
Aug 26, 2026
Merged

fix(console): settings selects follow the specifier's valueDomain declaration#6568
os-support-ai merged 2 commits into
mainfrom
claude/issue-3719-settings-valuedomain-combobox

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#3719

What was wrong

Since objectstack#5712 / PR objectstack#6581, a settings specifier can declare valueDomain (iana_time_zone | iso_4217_currency | iso_3166_alpha2). When it does, the standard's membership is the enforcement boundary and the curated options table is a UI convenience list — PUT /api/settings/localization accepts timezone: 'Europe/Zurich' and currency: 'CHF', neither of which the manifest lists.

The console kept rendering those keys as closed dropdowns. An admin could author only the 17 curated zones and 9 curated currencies, while the contract took the whole domain; every other legal value was reachable by API or OS_LOCALIZATION_* env only. The keys' own descriptions had promised "IANA zone" / "ISO 4217 code" all along.

⭐ Root cause, named because it will recur

Specifier in apps/console/src/pages/settings/types.ts is a hand-written local mirror of the server's shape, not an import — the file says so in its own header. Nothing tells it when the schema grows, and TypeScript reports nothing either, because a narrower mirror is a structurally valid reading of a wider object. The payload kept carrying valueDomain; only the renderer stopped seeing it. This PR adds the member and extends the header to say: when a settings feature "doesn't render", check this mirror against the zod schema first.

What changed

case 'select' in SettingsField.tsx now keys the control off the declaration, never off the key — so a key that gains a domain server-side gets the right control here with no edit.

  • Declared → an editable combobox. Native <datalist>: the curated options stay on as suggestions, free text is committed verbatim. Same suggest-but-allow-anything affordance the flow designer's FlowReferenceField already uses, for the same stated reason ("the designer must never trap the author") — zero new dependencies, built-in accessibility. An out-of-domain value is refused by the server with invalid_value + constraint: { valueDomain }, which lands in the field-error slot that already exists; the wrapper's aria-invalid / aria-describedby are forwarded onto the <input> rather than a wrapping node (same seam as Combobox's trigger pass-through, objectui#3318).
  • Undeclared → ⛔ the closed dropdown, untouched. This is half the change, not a caveat. Those options are still exhaustive under objectstack#5131 (the sms/mail provider selects), and localization.locale had its domain declaration deliberately rejected in objectstack#6515 because its options are the shipped catalogs. Widening those to free input would be a regression wearing this fix's clothes.

No manifest was touched and nothing in objectstack was touched; no specifier gained a valueDomain. The producer is correct.

Verification — the pin discriminates the two branches

__tests__/SettingsField.valueDomain.test.tsx derives the two groups from the specifier data (.filter(s => s.valueDomain)) over a fixture mirroring the real localization manifest, rather than listing which keys are which — a key that gains a domain server-side joins the right side of the pin with no edit. The probe values are keyed by domain, and each test first asserts its probe is genuinely outside that specifier's options.

Proving only that a domain-bearing key became a combobox would be evidence-identical to having replaced every settings dropdown with one, so both legs were ablated at f67045fe3. They fail on disjoint sets:

ablationredgreen
revert SettingsField.tsx to origin/main (fix removed)5 — the 3 declared keys, the count test, the aria test4 — the guard + all 3 undeclared keys
if (spec.valueDomain)if (true) (fix over-applied)4 — all 3 undeclared keys + the count test5 — the guard + all 3 declared keys

Each leg confirmed the mutation on disk before running (git hash-object differs from the HEAD blob; marker grep counts DomainCombobox 3 → 0, and if (spec.valueDomain) { 1 → 0 with if (true) { 1 present), and restored via git checkout HEAD -- <abs path> under a trap … EXIT INT TERM, with git diff HEAD empty afterwards. No build step is involved: the tests import ../SettingsField directly from source, so no dist/ can go stale between legs.

A first test also guards the .filter itself — both groups must be non-empty, or the two data-driven loops would pass while asserting nothing.

Gates — all green at 536fdde30 (the final commit)

legresult
pnpm exec vitest run apps/console/Test Files 81 passed (81) · Tests 920 passed (920)
pnpm --filter @object-ui/console type-checkexit 0
pnpm --filter @object-ui/console lint✖ 212 problems (0 errors, 212 warnings) — all pre-existing; my three files contribute 0 errors, 0 new warnings
pnpm check:control-bytes✅ OK (scanned 5421 tracked text file(s))
check-changeset-presence.mjs✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
pnpm changeset:check✅ No changeset declares a major bump.

Also green: check:phantom-deps, check:self-import, check:i18n-keys, check:vi-mock-specifiers, check:entry-guard.

Declared narrowing of the lint scope. Repo-wide lint is turbo run lint, per-package; this diff is entirely inside apps/console, so only that package's lint was run locally. Three pieces of evidence that this is a measurement and not a skipped run: (1) the population comes from eslint's own config, not my guess — eslint . --format json in apps/console selects 182 files; (2) all three touched files are present in that population, at e0/w0, e0/w0, e0/w3 (the three warnings on SettingsField.tsx are pre-existing any uses at lines 263/347/398, confirmed by git blame to predate this branch); (3) eslint.config.js enables no type-aware linting (no projectService, no parserOptions.project), so this diff cannot move the verdict on any untouched file in any other package. CI runs the full farm regardless.

Typecheck coverage is measured, not assumed:apps/console/tsconfig.json has include: ["src", "dev"] and no test exclude, and tsc --noEmit --listFiles confirms the new test file is in the program — so "typecheck clean" really does cover the new test.

Out of scope, filed not fixed

objectui#6567 — FormPage.predicateScope.test.tsx > hop1SessionPrincipal times out at 15s under full-project parallel load. Unrelated file, no import path from pages/settings/; green in isolation at this exact HEAD and green in two of three full runs. Filed unassigned, untouched here.


Generated by Claude Code

A settings specifier that declares `valueDomain` is judged against a
STANDARD, not against the manifest's `options` table — since
objectstack#5712 / PR objectstack#6581 the server accepts any IANA zone
or ISO 4217 code, so the curated 17 timezones / 9 currencies are a
convenience list. The console kept rendering those keys as closed
dropdowns, advertising a narrower domain than the contract enforces and
leaving every other legal value reachable by API or env only.
`case 'select'` now keys the control off the declaration: present -> an
editable combobox (native `<datalist>`, the same suggest-but-allow-
anything affordance FlowReferenceField uses); absent -> the closed
dropdown, untouched, because those options are still exhaustive
(objectstack#5131) and `localization.locale` had its domain declaration
deliberately rejected in objectstack#6515.
Root cause worth naming: `Specifier` in `pages/settings/types.ts` is a
hand-written local mirror of the server's shape, so nothing told it the
schema had grown. `valueDomain` is added there and the header now says
what to check when a settings feature "doesn't render".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3235.1 KB3266.6 KB
Main entry chunk (gzip)157.0 KB350 KB
Entry fileindex-BGPwzfEC.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.71KB4.46KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.01KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.91KB12.92KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.85KB20.79KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)63.21KB21.05KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review of #3719, done from both repos' trees.

The premise held, and your producer-side census is sharper than mine

I verified independently in objectstack: valueDomain is set on exactly three keys — localization.manifest.ts:40timezoneiana_time_zone, :78default_countryiso_3166_alpha2, :133currencyiso_4217_currency — against a closed three-member enum at settings-manifest.zod.ts:181.

default_country is a key my dispatch order did not name. I listed the three domains and left you to find the keys; you found that the third domain is used, and by a key neither the card nor I mentioned. That is the difference between relaying a card's vocabulary and censusing the producer.

⭐ And localization.manifest.test.ts pins both directions:41:43 assert the three declared, and :48 asserts every registry-backed key toBeUndefined() with the message "must not declare a domain". That upstream test is itself the model for what I asked of you here.

The fence held, and it is the half that mattered

Verified from the diff: the valueDomain-absent path is untouched. The only removed line is case 'select': gaining a block brace; the entire <Select> return is unchanged context. So localization.locale and the sms/mail provider selects keep their closed dropdowns, exactly as objectstack#5131 semantics and #6515's deliberate rejection require.

⭐⭐⭐ The ablation is the best-shaped one this lane has seen

Two legs failing on disjoint sets is precisely the discrimination the order asked for, and you named why the second leg exists rather than leaving it implied:

  • Leg A (revert SettingsField.tsx): 5 red — the three declared keys, the count, the aria case; green — the guard and all three undeclared keys.
  • Leg B (if (spec.valueDomain)if (true)): 4 red — all three undeclared keys and the count; green — the guard and the three declared.

Your own sentence is the point: "half 2 pins what must NOT change, so it cannot detect the fix's absence — leg B is what makes it non-vacuous." Leg A alone is satisfied by a change that turns every select into a combobox. Only the disjointness rules that out.

⭐ Mutation proven on disk by git hash-object against the HEAD blob plus anchored marker counts moving in both directions, with the script exiting 9 if a count did not move — so a "mutation" that silently failed to apply cannot masquerade as a passing ablation.

Two design calls I want on the record

Branching on presence, not on the member.types.ts says it plainly: "a domain added upstream still gets the right control before this union catches up." The union is mirrored closed for type-safety while the behaviour keys on presence — so a fourth valueDomain shipped server-side works in the console immediately. That is the correct asymmetry and it would have been easy to get backwards.

Native <datalist>, zero new dependencies. Suggest-but-allow-anything is exactly the affordance, it reuses the seam FlowReferenceField already uses, and it keeps this card off the human floor — a new runtime third-party dependency would have needed a maintainer.

⭐ The types.ts header now names the root cause better than my order did: "nothing tells this file when the server's shape GROWS … TypeScript reports nothing because a narrower mirror is a structurally valid reading of a wider object." That is the sentence that stops this recurring, and it is pointed at the next reader rather than at this card.

Scope and reporting

4 files, +390/−1 from the merge-base. No manifest, no objectstack file, no specifier gained a domain. check-changeset-presence asked for its verdict rather than assumed. The lint narrowing is a measurement — population from eslint's own JSON reporter, and type-aware linting off so this diff cannot move another package's verdict.

#6567 filed unassigned and unlabelled for the flaky FormPage.predicateScope timeout, with 254 open issues searched first. Correct handling: it is a different file with no import path from pages/settings/, green in isolation at your exact HEAD, and you resisted the temptation to fold it in.

Landing on green.


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review August 26, 2026 12:59
@os-support-ai
os-support-ai added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit ff8587fAug 26, 2026
30 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-3719-settings-valuedomain-combobox branch August 26, 2026 13:12
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Setup settings UI renders valueDomain-bearing keys as curated dropdowns — the UI advertises a narrower domain than the contract now enforces

2 participants

@os-support-ai@claude