Skip to content

fix(core): bare-string filter options — docs/examples stop teaching it, runtime lift warns (#4356) - #4601

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4356-shorthand-warn-now
Aug 13, 2026
Merged

yinlianghui merged 1 commit into
mainfrom
claude/issue-4356-shorthand-warn-now

Conversation

@yinlianghui

Copy link
Copy Markdown
Contributor

Phases 0+1 of #4356; Phase 2 is scheduled on objectstack-ai/objectstack#7917. Not Fixes — merging this does not close the card, because removing the lift is still outstanding.

The ruling chain

  1. normalizeFilterOptions accepts a bare-string globalFilters[].options shorthand that @objectstack/spec rejects at publish #4356 recorded the divergence: @objectstack/spec's GlobalFilterSchema.options accepts only { value, label } pairs, while @object-ui/core's normalizeFilterOptions also lifted a bare-string shorthand. A dashboard authored with the shorthand rendered correctly in objectui and was refused the moment it reached the platform — the "one strict contract beats N dialects" case AGENTS.md #0.1 names, with the renderer's tolerance acting as a second de-facto contract that hides the producer's bug.

  2. Routed upstream as objectstack#7917 and ruled by the maintainer on 2026-08-12, verbatim:

    「7918 A,7917 ②,7900 收敛两扇门,7929 来源标记」

    Option ② — the spec stays strict; the runtime lift retires behind a deprecation window sized by a stored-dashboard survey.

  3. The survey was delivered and ACCEPTed on objectstack#7917. It found the shorthand's source: objectui's own docs and its schema-catalog corpus still taught the form, so the stored population was still growing. It asked for Phase 0 (stop teaching it) to ship together with the ruled Phase 1 (warn), since warning authors while the docs still teach the form is a contradiction users report as a bug.

  4. The type half already matches the ruling — objectui: dashboard KPI cards drop the translated widget title/description — the self-contained metric path bypasses tWidgetTitle, and the plugin's private resolveLabel never calls t() #4032 / PR fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032) #4358 bound DashboardComponentSchema.globalFilters to the spec. Untouched here.

Phase 0 — the teaching census, re-verified

The survey named 6 non-test occurrences. Re-measured on origin/main at 92250d648 with a structural scan (bracket-match each globalFilters array, classify each options member, any-position pass so mixed arrays are caught), all 6 confirmed and corrected to the pair form:

# Occurrence Was
1 content/docs/guide/dashboard-filters.md:98 ["EMEA", "APAC", "AMER"]
2 content/docs/plugins/plugin-dashboard.mdx:165 ["EMEA", "APAC", "AMER"]
3 packages/plugin-dashboard/README.md:222 ["EMEA", "APAC", "AMER"]
4 examples/schema-catalog/.../filtered-dashboard.json:17 ["EMEA", "APAC", "AMER"]
5 examples/schema-catalog/.../filtered-dashboard-dataset-widgets.json:17 ["EMEA", "APAC", "AMER"]
6 examples/schema-catalog/.../filtered-dashboard-target-widgets.json:12 ["draft", "sent", "paid", "void"]

The true census is 7, not 6. A structural scan sees code blocks; it does not see prose. content/docs/guide/dashboard-filters.md also carried a paragraph presenting the shorthand as an equal alternative — "Static options accept the @objectstack/spec object form … or a bare-string shorthand … the runtime normalizes both". That sentence taught the form more directly than any of the six code blocks, and it is corrected here too: the canonical form is stated as the only one the platform accepts, with the shorthand demoted to a deprecation callout carrying the migration rule.

Two test occurrences were also migrated, following the precedent already set in DashboardRenderer.filters.test.tsx (which a previous card migrated with exactly this reasoning — the survey's count of 5 test hits is now stale at 3):

  • DashboardWidgetInspector.test.tsx:138 — purely incidental scenery; nothing in that suite reads the option list.
  • DashboardFilterBar.i18nLabel.test.tsx:165 — a mixed array. Its mixed-lift coverage is not lost: it is replaced by a dedicated, stronger pin in core's own suite (see below).

The third test occurrence, in packages/core/src/utils/__tests__/dashboard-filters.test.ts, is the one that pins the lift and stays — now capturing the warning instead of leaking it into the suite's output.

Phase 1 — the warning

normalizeFilterOptions keeps the lift, byte-identically. It is mechanically lossless ('EMEA' becomes { value: 'EMEA', label: 'EMEA' }), and stored dashboards carry the shorthand, so dropping it silently would turn a rendering filter into an empty one.

Design:

  • Identity. The warning names the filter, quotes the offending values, and prints the canonical replacement. The identifying context is the name local that resolveDashboardFilterDefs had already resolved — nothing new is threaded through any public signature. normalizeFilterOptions is module-private, so widening its parameter list is not a contract move.
  • Dedupe. Module-level memo keyed by filter name and offending values. Name-only would report the first dashboard carrying a shorthand status filter and stay silent about every other one, sending the author to fix one symptom; values-only would collapse two genuinely different filters that share an option list. Same reasoning warnOnUnknownActionKeys records for its own memo.
  • Once per session, not per render. resolveDashboardFilterDefs runs on every dashboard render; a warning without the memo floods the console per frame, and a warning that floods is a warning that gets muted.
  • Convention. Matches warnOnDeprecatedObjectParams in packages/core/src/actions/actionKeys.ts — the repo's existing precedent for exactly this shape (a spec-refused authoring form the runtime still reads for one version window): dev-mode-only gate, module-level Set memo, exported reset for tests. Prefix [dashboard-filters] via the module's existing warnDateFilter.
  • No false positives. Silent on canonical object options — otherwise it would fire on every healthy dashboard in the product. A mixed array names only its bare members; partial migrations happen, and re-reporting already-canonical members sends the author back to options they just fixed.
  • Quiet in our own suites. The three in-repo test fixtures that carried the shorthand either moved to the pair form or now capture the warning, so it fires only in the tests that assert it.

The guardrail, and a falsified premise

The survey suggested parsing each catalog entry with DashboardSchema and requiring safeParse to succeed. Measured, that is not implementable — and the reason is not the shorthand. All 9 plugin-dashboard entries are refused by DashboardSchema today and stay refused after this fix:

  • they are objectui SDUI component schemas, not stored platform metadata documents, so they carry no name / label identity keys — 2 issues per entry before any widget is read;
  • most of their widgets use the pre-ADR-0021 inline analytics shape (object + categoryField + aggregate) that @objectstack/spec 17 removed.

A DashboardSchema.safeParse assertion would be permanently red, and making it green would mean rewriting all 9 examples into platform-metadata shape — far larger than this card. That divergence is real and filed separately (see below); it is deliberately not smuggled in here.

So the guardrail is pinned at the exact spec sub-schema that owns the surface this card governs: GlobalFilterSchema, applied to every globalFilters[] entry in every plugin-dashboard catalog example. Same schema the platform runs, over the property that actually regressed.

Discrimination proof — the guardrail run against the pre-fix catalog JSONs (fix taken out with git checkout origin/main --, restored and sha256-verified):

❯ examples/schema-catalog/test/plugin-dashboard-global-filters-spec.test.ts (9 tests | 3 failed)
  × plugin-dashboard/filtered-dashboard globalFilters[0] is accepted by GlobalFilterSchema
  × plugin-dashboard/filtered-dashboard-dataset-widgets globalFilters[0] is accepted by GlobalFilterSchema
  × plugin-dashboard/filtered-dashboard-target-widgets globalFilters[0] is accepted by GlobalFilterSchema
+   "[options.0] Invalid input: expected object, received string",
+   "[options.1] Invalid input: expected object, received string",
+   "[options.2] Invalid input: expected object, received string",
 Test Files  1 failed (1)
      Tests  3 failed | 6 passed (9)

Red on exactly the 3 defective entries; green in both directions on filtered-dashboard-dynamic-options and filtered-dashboard-filter-types (4 filter assertions) — those are the positive controls proving the sweep reaches real filters rather than passing vacuously. A sweep actually reaches filters pin guards the it.each against reporting nothing, since it.each([]) is silently green.

Post-fix: 9 passed.

Red-first, verbatim

Warning pins, with only the warnShorthandOptions call removed (surgical — the export stays, so the red is an assertion failure and not an import error):

× still lifts a bare string, byte-identically, AND warns
× warns ONCE per offending filter across repeated renders, not once per render
× warns separately for a DIFFERENT filter — the memo is not a global mute
× names ONLY the bare members of a MIXED array
 Test Files  1 failed (1)
      Tests  4 failed | 40 passed (44)

4 of the 5 new pins go red. The fifth is honestly not a discrimination proof and is labelled as such in the file: says NOTHING for canonical options passes vacuously against a build with no warning at all. Its value is directional the other way — it goes red if the warn ever starts firing on healthy dashboards. Predicted in writing before the run, and it came out as predicted.

Must-not-change — all held

One in-repo surprise, fixed rather than absorbed: my explanatory comment quoted the expression f.name alternated with f.field, and column-identity.ratchet.test.ts is a deliberately line-level scanner, so the comment read as a second dual read and failed the ratchet count. Reworded the comment instead of bumping the inventory to 2 — inflating the count for a comment would mask a future real read.

Verification

Grading

@object-ui/core: minor. The dispatch predicted a byte-identical .d.ts and therefore patch; measured with dist and tsbuildinfo cleared both ways, that assumption is false — the emitted .d.ts gains exactly one declaration:

> /** Reset the shorthand-options warn-once memo. Exported for tests. */
> export declare function resetDashboardFilterWarnings(): void;

plus doc-comment prose. Purely additive; nothing narrowed, widened or removed, so no existing consumer breaks. Re-graded by position analysis and by the repo's own precedent: resetActionKeyWarnings — the same reset-the-warn-memo export, in the same package, alongside the same kind of deprecation warning — was graded @object-ui/core: minor in 62311b6. Never major.

plugin-dashboard, app-shell and the schema catalog need no bump of their own: README-only, test-only, and @object-ui/example-* is in the changeset ignore list. All packages are in one fixed group regardless.

Out of scope, filed separately

The whole-document spec divergence measured above — 9/9 plugin-dashboard catalog entries refused by DashboardSchema, mostly for the pre-ADR-0021 widget shape — is a real finding and strictly larger than this card. Filed unassigned as #4600 rather than fixed here.


Generated by Claude Code

…t, runtime lift warns (#4356)

Phases 0+1 of objectui#4356, under the maintainer ruling of 2026-08-12 on
objectstack#7917 (verbatim 「7917 ②」): the spec stays strict; the runtime
bare-string lift retires behind a deprecation window sized by a stored-dashboard
survey. Phase 2 (removing the lift) is scheduled on objectstack#7917 and is
deliberately NOT here.

Phase 1 — normalizeFilterOptions keeps the lift, unchanged and mechanically
lossless, and now logs a deprecation warning naming the offending filter, the
offending values, and the canonical pair form. Warn-once per offending filter
per session (this runs on every dashboard render) and dev-mode only, matching
the warnOnDeprecatedObjectParams convention in actions/actionKeys.ts. Silent on
canonical object options; a mixed array names only its bare members.

Phase 0 — all seven non-test teaching occurrences corrected to the pair form.
The survey named six; a seventh is a prose passage in the dashboard-filters
guide that presented the shorthand as an equal alternative, which a code-block
scan does not see.

Guardrail — every globalFilters[] entry in every plugin-dashboard schema-catalog
example is now parsed with the real @objectstack/spec GlobalFilterSchema, with a
non-vacuity control. The catalog previously asserted only structure and
render-without-throw, which is how a spec-invalid example got in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 5:13pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation package: core plugin examples tests labels Aug 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-DtxyaJBo.js —
Status PASS —

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.33KB 108.47KB
core (index.js) 3.79KB 1.52KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 163.56KB 44.83KB
fields (index.js) 230.37KB 57.17KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 121.04KB 31.57KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.93KB 60.01KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.30KB 40.02KB
plugin-grid (index.js) 189.37KB 50.33KB
plugin-kanban (index.js) 52.74KB 14.53KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.38KB 11.09KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.09KB 20.56KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.64KB 9.44KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.26KB 0.67KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 17:31
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit f279deb Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4356-shorthand-warn-now branch August 13, 2026 17:31
huangyiirene pushed a commit that referenced this pull request Sep 28, 2026
…bjectui#4356)

`normalizeFilterOptions` no longer lifts a non-object `options` member to a
`{ value, label }` pair: the member yields no option, a shorthand-only filter
resolves with no `options`, and a mixed array keeps only its object members.
The Phase 1 deprecation warning (PR #4601), whose text promised the lift, goes
with the arm; a dev-mode, once-per-filter warning names the dropped members
and the rewrite instead (ADR-0078 section 4), so `resetDashboardFilterWarnings`
keeps its memo and stays exported.

Rulings: objectstack#7917 「7917 ②」 (2026-08-12) and 「objectstack#7917
不考虑现有数据」 (2026-09-02). The docs callout and the `GlobalFilterSchema`
docblock in @object-ui/types stop describing the lift. Changeset: core minor.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…bjectui#4356) (objectstack-ai#10930)

Fixes objectstack-ai#4356

Clause-②: yes — published runtime behaviour is removed (the bare-string
lift, and the exported `resetDashboardFilterWarnings` if it retires with
its memo), per the execution parameters in `5511028727`.

The claim's "if" resolved to **stays**: the memo survives (it now
dedupes the dropped-member warning, see "The audible-skip question"), so
`resetDashboardFilterWarnings` keeps its job and its export; the
changeset names no removed export.

Final head `a890654027a69911412efbcb146e326da8476256`. Commits on this
branch since the branch point `733fd5ac6`: `bc4ea976e` (the retirement),
`98623a24d` (rework round 1: W1–W3), `e0647486b` (patch round 2: W5),
the two merges of `origin/main` (`1daf7edf7` at `29c76e573`, `a1058f90e`
at `2eaf5be27`), and `a89065402` (W4, the dated note). Implemented in
session `session_012UwY3ahMixEFkfTUxMVkYm`.

## What changed

Phase 2 of objectui#4356 — the runtime bare-string
`globalFilters[].options` lift retires.

- `packages/core/src/utils/dashboard-filters.ts` — in
`normalizeFilterOptions`, the non-object arm no longer pushes `{ value:
String(o), label: String(o) }`. A member that is not an object (string,
number, boolean) yields NO option: a shorthand-only filter resolves with
no `options`, a mixed array keeps only its object members. The nullish /
value-less skips are unchanged. The Phase 1 deprecation warning
(`warnShorthandOptions`, whose text said "Still lifted here") is
deleted; a dev-mode, once-per-filter `warnDroppedShorthandOptions` names
the filter, the dropped members and the rewrite, and promises no lift.
The `DashboardFilterDef.options` JSDoc, the `normalizeFilterOptions`
docblock, the memo docblock and the `resetDashboardFilterWarnings`
docblock stop describing a lift. No lift, alias, fallback or migration
is added anywhere.
- `packages/core/src/utils/__tests__/dashboard-filters.test.ts` — every
lift-pinning test is flipped to pin the new contract (red-first, quoted
under Tests): `normalizes options: spec {value,label} objects AND
bare-string shorthand` became `normalizes spec {value,label} objects,
and DROPS a bare-string member rather than lifting it`; the `[objectstack-ai#4356]`
describe is `retired: dropped, not lifted` — a shorthand-only filter
yields no `options`; a mixed array keeps only its object members; a
number and a boolean are dropped too; warn ONCE across renders while
dropping on every render; a different filter warns separately; canonical
`{ value, label }` options and an `I18nLabel` map are untouched and
silent; `resetDashboardFilterWarnings` re-arms the memo. The stale Phase
1 header above the block is removed (W3).
- `content/docs/guide/dashboard-filters.md` — the "Deprecated: … still
lifted by the runtime" callout became false and now reads "Not
accepted": refused at publish, no longer lifted, a bare-string member
yields no option, rewrite `"X"` as `{ "value": "X", "label": "X" }`. No
fenced block changed.
- `packages/types/src/zod/complex.zod.ts` — the JSDoc of the exported
`GlobalFilterSchema` said the runtime "still LIFTS a stored bare-string
option on read"; that sentence ships in `@object-ui/types`' emitted
declarations and is corrected in place. Outside the claim's original
surface; accepted by the seat's addendum, see "Deviations".
- `packages/plugin-dashboard/README.md` (W1, ships in that package's
`files`) — the `globalFilters` example's `options` comment no longer
says a stored bare string "is still lifted at runtime, and warns"; it
says NOT accepted, the runtime no longer lifts it (objectui#4356), a
bare member yields no option, a dev-mode warning names it, rewrite each
X as `{ "value": X, "label": X }`.
- Three test docblocks state the retirement instead of the lift (W2):
`packages/types/src/__tests__/dashboard-global-filters-spec-binding.test.ts`,
`packages/types/src/__tests__/report-chart-query-spec-parity.test.ts`
(its header docblock only — nothing near objectui#10916's
`JoinedReportBlock` pin, which PR objectstack-ai#10934 rewrote and this branch has
merged),
`packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx`.
A fourth (W5),
`packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx`,
no longer says the shorthand "is deprecated and now warns" nor names the
renamed lift pin; it names the current DROP pin. No assertion moved in
any of the four.
- `.changeset/retire-options-shorthand-lift-4356.md` — `@object-ui/core:
minor`, opening with the ruled wording: "bare-string
`globalFilters[].options` is no longer lifted; use `{ value, label }`
objects, the spec's form". Never `major` (AGENTS.md §9 version policy:
the break is stated in the body, the bump stays `minor`);
`check-changeset-no-major` exit 0.
- `.changeset/7759-dashboard-header-global-filters-spec.md` (W4) — a
dated note appended, nothing else touched. That pending entry says "The
runtime is unchanged too: `@object-ui/core` still converts a STORED
bare-string option into a pair when it reads the document, and logs a
deprecation warning. That conversion keeps its own retirement schedule
(objectstack#7917)." — a sentence this PR makes false in the same
release. The note (PR objectstack-ai#10891 form, dated 2026-09-29) says which
sentences no longer hold and what ships. Authorised by the maintainer's
ruling of 2026-09-29, option 「Allow the appended note (Recommended)」,
recorded on the card as `5883360525`; the option reads, verbatim: "You
allow developers to append dated correction notes to pending .changeset
files (appended only, no existing line or frontmatter edited). The
objectstack-ai#10930 developer adds the W4 note inside that PR, then it merges main,
gets a final review, and merges." Measured: 16 insertions, 0 deletions;
frontmatter md5 `ac1bfd0472c0d0b981d923c32d435f18` before and after;
whole-file md5 `94edd5855709afe137f9184c630f298c` →
`5c8f0508bed5ed6f5b76254bb74d622d`; 0 angle-bracket characters; `diff`
against `origin/main` shows only the appended block.

Untouched on purpose:
`examples/schema-catalog/test/plugin-dashboard-global-filters-spec.test.ts`
(the `GlobalFilterSchema` catalog guardrail from PR objectstack-ai#4601 — the positive
control; its docblock narrates history and spec facts only, and stays
true); the assertions of the two `packages/types` tests that pin the
SPEC's refusal of `options: ['EMEA']` (rejection pins, not lift pins);
`@objectstack/spec`; `content/docs/releases/**`; `skills/**` (no file
there becomes false — measured below).

## Rulings executed

- objectstack-ai/objectstack#7917, maintainer 2026-08-12, verbatim 「7917
②」 — the spec stays strict; the runtime bare-string lift retires.
- Maintainer, 2026-09-02, verbatim 「objectstack#7917 不考虑现有数据」 (director
release `5511028727` on objectui#4356) — Phase 2 on release cadence
alone; no live-tenant survey, no migration entry.
- Maintainer direct dispatch to the `domain:spec @ objectui` seat,
verbatim 「这个你也接管继续
objectstack-ai#4356」.
- Maintainer, 2026-09-29, 「Allow the appended note (Recommended)」 (card
comment `5883360525`) — authorises the W4 append in exactly the form
landed here.

## The audible-skip question — governing text found

The dispatch asked whether any governing text requires the post-removal
skip to be audible. What was read, verbatim:

- objectstack `AGENTS.md`, "Degradation log levels — `warn` vs `error`":
"**Functional degradation → `warn` / `info`.** A screen is missing, a
trigger is not armed, a capability is not enabled … The system is
*visibly* smaller than it should be, and the next person to use the
missing thing finds out." and "**prefer failing to falling back**, and
when you must fall back, say what was lost." — A `select` filter whose
members were all bare strings now renders with an empty option list
(`DashboardFilterBar`: `const authored = def.options?.length ?
def.options : (dynamicOptions ?? [])`): visibly smaller, functional,
`warn`-level — and "say what was lost" is the operative sentence.
- objectstack `docs/adr/0078-no-silently-inert-metadata.md`, §4
"Loud-not-silent at the runtime seam": "Where the runtime *skips* an
inert instance … it emits a **dev-mode diagnostic** at registration
rather than swallowing it silently. This is the only layer that is
**authoring-tool-agnostic**". The ADR's completeness gate (§1) is about
Zod-VALID instances and a bare string is Zod-invalid, but §4 is exactly
the case here: a STORED dashboard is the one document no author-time
gate re-reads.
- objectui `AGENTS.md`: no clause on audibility; #0.1 (contract-first,
no lenient fallback) is honoured — a diagnostic is not a fallback, and
nothing is lifted.
- The module's own convention, same file: `buildFilterCondition` —
"anything else → skipped with a console warning, never silently
downgraded"; `buildWidgetScopedFilter` skips an unknown default field
"with a console warning instead of emitting a query".

Conclusion: yes, at `warn`, dev-mode, deduped — the smallest audible
path consistent with the ruling. Implemented as
`warnDroppedShorthandOptions`: same `isDev()` gate and memo key as Phase
1 (no per-render flood), new sentence (DROPPED, "no longer lifted", the
rewrite), no lift. Consequence: the memo and therefore
`resetDashboardFilterWarnings` stay. The contract review (`5866198610`)
judged this RIGHT; dev-only rather than always-on is the same review's
answer for this PR.

## Measurements against the dispatch's mechanism assumptions

1. Lift live on `origin/main` `733fd5ac6` — confirmed: the `else` arm
pushed `{ value: String(o), label: String(o) }` and called
`warnShorthandOptions` for EVERY non-nullish primitive. Pinned now in
`drops EVERY non-object member — a number and a boolean, not only a
string`.
2. `resetDashboardFilterWarnings` — `git grep` repo-wide: importers are
`dashboard-filters.test.ts` only (plus a `packages/core/CHANGELOG.md`
paragraph); it clears only `warnedShorthandOptions`. The inference "with
the memo gone it has no job" is falsified by (4): the memo does not go.
It stays, with a docblock saying why.
3. Fixture recount, repo-wide over every file containing `globalFilters`
(tests included; positive control: the same matcher found the callout it
was meant to find): lift-pinning bare-string members existed only in
`dashboard-filters.test.ts` — six members across five tests (the
dispatch's list lacked the bare `'lost'` of the mixed-array test; the
director's "three" and the dispatch's "five" were both stale). All six
retired or flipped. The two `packages/types` occurrences pin the spec's
REFUSAL and keep their assertions.
4. Skip audibility — see the section above.
5. Teaching sites — swept at the final head with `git grep -n -i -E
'lift' -- ':!**/CHANGELOG.md' ':!content/docs/releases/**'
':!pnpm-lock.yaml' | grep -i -E 'shorthand|bare[- ]string|4356'` over
the whole tracked tree (a superset of every package's `files[]`,
`content/docs`, `skills/`, `examples/`): 20 hits on `bc4ea976e` with
`packages/plugin-dashboard/README.md`'s "still lifted at runtime" line
as the found positive control; 18 on `a89065402` — every hit either
states the retirement (this PR's changeset, source, docs and test text)
or is the unrelated `data`-array shorthand in `plugin-grid` /
`plugin-map`. The first-round sweep's `option|filter` post-filter is
what hid the README line (W1) and the `i18nLabel` comment (W5); the
rework sweep has no post-filter. When the sweep runs on a ref, the ref
prefix is stripped before the second grep — this branch's name contains
both "shorthand" and "lift". Three other sites author `options: ['…']`
on `select` FIELDS (form / object field schemas:
`content/docs/guide/expressions.md`,
`content/docs/guide/schema-playground.md`,
`skills/objectui/guides/project-setup.md`) — a different contract, out
of this card, untouched; the governed `skills/**` file does not become
false.
6. Release — `packages/core/package.json` is `17.6.0`;
`.changeset/config.json` has `@object-ui/core` in the `fixed` group; a
`minor` changeset makes the next release ≥ 17.7.0; release PR objectstack-ai#5400 is
open (not touched).
7. Pending changesets — swept on the merged tree (2326 pending entries)
with `git grep -n -i -E 'normalizeFilterOptions|bare[-
]string|shorthand|lift' -- '.changeset/*.md'` filtered to option /
`globalFilters` / 4356 / 7917 context: exactly ONE pending sentence this
PR makes false, the `7759` paragraph quoted above (W4). Its earlier
sentence "the bare-string option shorthand (`options: ['EMEA']`) is
refused with `invalid_type`" stays true. Every other hit is a different
contract (export-options bare array, object-map / gantt `data` arrays,
kanban `columns`, picklist / field `options`, widget shorthand). The
nine entries the `changeset-claim-re-read` comment `5865883972` lists
were re-read: none says anything about the options lift; all still true.

## Merges of `main`

- `1daf7edf7` — `origin/main` `29c76e573` (PR objectstack-ai#10934, objectui#10916).
No conflicts; `report-chart-query-spec-parity.test.ts` auto-merged
(disjoint hunks); both sides verified — my header-docblock correction
present, PR objectstack-ai#10934's pin text byte-for-byte as `main` has it.
- `a1058f90e` — `origin/main` `2eaf5be27` (70 commits, among them PR
objectstack-ai#10914's citation edits in `dashboard-filters.ts` and three
`complex.zod.ts` commits). No conflicts; after the merge the files
differing from `origin/main` are exactly this PR's ten, and `git diff
origin/main` on `complex.zod.ts` and on the parity test is exactly this
PR's docblock hunks.

No rebase, no amend, no force-push at any point; every push was a plain
`git push`.

## Tests

Red-first (at `bc4ea976e`, the new pins against the UNTOUCHED source,
`pnpm exec vitest run
packages/core/src/utils/__tests__/dashboard-filters.test.ts`, exit 1):

```text
× normalizes spec {value,label} objects, and DROPS a bare-string member rather than lifting it (objectui#4356)
× DROPS a bare string: a shorthand-only filter resolves with no `options`, and says so
× keeps ONLY the object members of a MIXED array, and names only the dropped ones
× drops EVERY non-object member — a number and a boolean, not only a string
× warns ONCE per offending filter across repeated renders, and drops on every one of them
AssertionError: expected [ …(2) ] to be undefined
AssertionError: expected [ Array(2) ] to deeply equal [ { value: 'won', label: 'Won' } ]
AssertionError: expected [ { value: '42', label: '42' }, …(2) ] to deeply equal [ { value: 'gold', label: 'Gold' } ]
 Test Files  1 failed (1)
      Tests  5 failed | 44 passed (49)
```

The three that stayed green on the old code are the ones the block
labels as non-discriminating (different-filter memo, canonical silence,
memo reset). Green after the source change, same command: `Tests 49
passed (49)`.

Final head `a89065402`, one `pnpm exec vitest run` from the repo root
under the shared verify lock over `packages/core/` plus the two
`packages/types` global-filter tests and the two `plugin-dashboard`
dashboard-filter tests this PR touched: `Test Files 188 passed (188)` ·
`Tests 3725 passed | 27 skipped (3752)`, exit 0. The changeset script
tests (`check-changeset-{presence,no-major,fixed,overwrite,claims}`,
`check-pending-changeset-literals`, `changeset-polarity-census`): `Test
Files 7 passed (7)` · `Tests 292 passed (292)`, exit 0.

Consumer sweep at `bc4ea976e` (`resolveDashboardFilterDefs` consumers
re-derived by `git grep`: `core`, `types`, `plugin-dashboard`,
`plugin-designer`, `app-shell`, `examples/schema-catalog`), exit 0:
`packages/core` 182 files / 3643 tests; `packages/plugin-dashboard` 147
/ 1346 (6 pre-existing skips); `packages/types` 3 / 58;
`packages/plugin-designer` (`DashboardDesignPage.presetAlias`) 1 / 4;
`packages/app-shell` (`DashboardWidgetInspector.test.tsx`) 1 / 19;
`examples/schema-catalog` (the guardrail) 1 / 9 — totals `335 passed
(335)` · `5073 passed | 6 skipped (5079)`. No consumer fixture asserted
the old lift. The code has not changed since that head (every later
commit is prose or a merge), and the final-head run above re-covers
`core`, `types` and the touched `plugin-dashboard` tests.

## Gates on the final head `a89065402` (hand-derived from root
`package.json` and `.github/workflows/`; exit codes captured before any
pipe)

| gate | exit | note |
|---|---|---|
| `pnpm --filter '@object-ui/core^...' build` then `pnpm --filter
@object-ui/core build` | 0 / 0 | dependency closure first, so the
type-checks read rebuilt declarations |
| `pnpm --filter @object-ui/core type-check` · `pnpm --filter
@object-ui/types type-check` | 0 / 0 | |
| `pnpm --filter @object-ui/core lint` · `pnpm --filter @object-ui/types
lint` | 0 / 0 | 0 errors; the warnings are pre-existing
`no-explicit-any` — on the touched files the `as any` counts equal the
base (2 / 10 / 0), unchanged since `bc4ea976e` |
| vitest `packages/core/` + the four touched dashboard-filter tests
(under the lock) | 0 | 188 files, 3725 passed, 27 skipped |
| vitest changeset script tests ×7 | 0 | 292 passed |
| `node scripts/check-changeset-presence.mjs` | 0 | 11 files changed vs
merge-base `2eaf5be27`, 7 published source files of 3 released packages,
1 changeset added |
| `node scripts/check-changeset-no-major.mjs` ·
`check-changeset-fixed.mjs` · `check-changeset-claims.mjs` ·
`check-changeset-overwrite.mjs` | 0 ×4 | claims and overwrite are
report-only; the W4 append is overwrite's documented "correcting a
declaration on purpose" case |
| `pnpm check:pending-changeset-literals` | 0 | "No test source names a
pending changeset" |
| `node scripts/check-control-bytes.mjs` | 0 | 9331 tracked text files |
| `node scripts/check-new-cross-file-line-citations.mjs` | 0 | 0 new
citations (the W4 note cites by quoted sentence) |
| `node scripts/check-governed-queue-guard.mjs --test` (every touched
path) | 0 | NOT GOVERNED |
| `pnpm check:doc-fences` · `check:doc-types` · `check:doc-example-ids`
· `check:spec-symbols` · `node scripts/check-doc-links.mjs` ·
`check:test-path-roots` · `check:vi-mock-specifiers` ·
`check:phantom-deps` | 0 ×8 | measured at `bc4ea976e` / `98623a24d`; the
files they read have not changed since |
| `pnpm check:doc-snippets` · `pnpm check:readme-exports` | 2 / 1 | NOT
MEASURED locally — both `PRECONDITION NOT MET` (unbuilt packages); CI's
Doc Snippet Type Check and README Exports are the readings. The docs
hunk changes no fenced block; the README hunk is a JSON comment with no
import binding |

Not run locally, CI-owned: repo-wide `pnpm lint`, `pnpm test`, `pnpm
type-check` (the public type face is unchanged — no export added or
removed, no signature changed; emitted declarations differ in JSDoc text
only), `pnpm check`, the i18n gates, `plugin-dashboard`'s own type-check
(comment-only hunks in two test files).

## Deviations

- `packages/types/src/zod/complex.zod.ts` was outside claim
`5865368364`'s original file surface; edited under the bounded
in-place-fix exemption (same defect class; mechanical; no other open PR
on the file; inside core's type-checked closure) and accepted by the
seat's addendum `5865980488`. The later addenda `5866223135`,
`5866346457`, `5866480991` added the W1–W5 files and the `7759`
changeset.
- The first-round teaching sweep under-reported (W1, W5) because of its
`option|filter` post-filter; corrected above.
- The W4 append was first refused by the auto-mode classifier ("Modify
Shared Resources", record `5866443287`), held and not re-routed; it
landed only after the maintainer's 2026-09-29 ruling.

## Acceptance notes

- `warnDroppedShorthandOptions` is dev-only, exactly as the Phase 1
warning was; the module's other skip warnings are not dev-gated.
Always-on is a one-line product decision the review left to the
maintainer; not this PR's.
- Field-level `select` schemas teaching `options: ['…']` are a different
contract and were not judged here — observation only, not filed.
- `packages/core/CHANGELOG.md` and
`packages/plugin-dashboard/CHANGELOG.md` narrate the lift as history;
published record, not re-addressed (the CHANGELOG clause under AGENTS.md
objectstack-ai#11: never re-address a paragraph in a published CHANGELOG).
- PR objectstack-ai#10914 (objectui#10803 batch 6) has landed on `main` and is merged
into this branch; no other open PR touches this PR's files.

---
_Generated by [Claude
Code](https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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 examples package: core plugin tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants