Skip to content

fix(app-shell): report a malformed picklist option instead of rendering it blank and deleting it - #9075

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-8632-malformed-picklist-option-loud
Sep 10, 2026
Merged

fix(app-shell): report a malformed picklist option instead of rendering it blank and deleting it#9075
os-steve merged 2 commits into
mainfrom
claude/issue-8632-malformed-picklist-option-loud

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #8632

A malformed picklist option in the field designer is now reported on screen and preserved, instead of rendering as a blank row and being deleted from the draft on the next touch of the option editor.


⚠️ Read this first — this narrows what the designer will save

A document containing a malformed picklist option used to become saveable, because the designer silently deleted the offending entries before the author ever saw them. It no longer does. The entry is written back byte for byte, so the draft keeps failing FieldSchema until the author repairs or removes it.

This is not a refusal route — nothing in the UI blocks an edit or a save. What is removed is a silent auto-repair that turned an invalid document into a valid one without telling anyone. The escape path is one click on the malformed row's Remove button, which reproduces exactly the old outcome with the author choosing it, and it is pinned.

The claim comment on objectui#8632 declares Clause-②: yes, which is correct for this route; no declaration is changed by this PR.


The defect, and which half carried the severity

ObjectFieldInspector's readOptions opened with value: String(o?.value ?? ''). Two consequences, measured on the unfixed reader with options: ['draft', 'open', 'closed']:

trigger before after
render 3 rows, values ["","",""], labels ["","",""] 3 marked rows naming the reason, authored entry shown verbatim
click "Add value", type nothing options: [] options: ['draft','open','closed']
one reorder click options: [] options: ['open','draft','closed']
type alpha into row 0 options: [{value:"alpha",label:""}] siblings preserved

The PR is written against the deletion, per the ruling: the author opens a picklist, sees an empty-looking option list, clicks the obvious button, and three authored options are gone before a character is typed — unseen, unwarned, unattributable.

The PM's reachability claim, corrected

⚠️ Both the card and the PM note describe the deletion as reachable by "an edit to any other control on that field" — open the record to change something else, save, close. That is not what happens. patchDef spreads def and patches only the keys handed to it, so options travels untouched: an edit to Description, Label or Required never rewrote the option list, before this PR or after. Measured, and pinned as a control so it is not re-asserted.

The real trigger is any interaction inside the option editor — which makes the reachability worse than claimed, not better: it costs one click on the button an author looking at an empty list would reach for first, with nothing typed.

"Malformed" was two families, not one shape

The card names a bare string. Enumerated against the reader, every shape measured, and a repair that caught only the first family would have looked complete:

Family 1 — collapsed to a blank row, then deleted: a bare string, null, a number, a boolean, {}, an option with no value, an authored empty value, a null value, a nested array. (9 shapes)

Family 2 — silently REWRITTEN into a different document, never deleted, never visibly wrong: { value: 5 } was written back as "5"; { value: true } as "true"; { value: ['alpha'] } as "alpha", which is indistinguishable on screen from a well-formed option; { value: { a: 1 } } as the literal string [object Object]; a non-string label as label: ''; a non-string color was dropped from the document entirely. (6 shapes)

Family 2 is the AGENTS.md #0.1 tolerance already living in the file. 15 shapes total, all covered by one rule.

What changed

The reader is now strict — the coercion is removed, not widened (AGENTS.md #0.1; triage: "that ban is the ruling, not an obstacle"). classifyOption sorts each authored entry into a row this editor owns or a row it refuses to represent. A refused entry gets a marked row naming the reason, showing the authored entry verbatim, carrying the same reorder/remove controls as any other row, and is written back byte for byte on every commit.

Two boundaries deliberately not crossed, both prior rulings rather than oversights:

  • an option with no label key still commits label: '' — the objectui#7014 Q2 ruling, pinned as a control;
  • a value the spec rejects but this editor can show ('a', the two-character minimum) stays an ordinary editable row. The draft validator names it; this reader reports only what it cannot show.

The value.trim() !== '' filter in OptionsEditor.commit is unchanged. It is correct for the row it was written for — this editor's own blank trailing row — and the repair is upstream: an authored entry no longer arrives wearing that row's shape.

Ablation — prediction written before the run

Mutation, one line in OptionsEditor.commit: r.kind === 'malformed' || … becomes r.kind !== 'malformed' && …, restoring the destructive half and leaving the reporting half intact. Direction predicted: turns red.

  • On-disk proof. HEAD blob 4a98dfb6…; anchor occurrences 1 to 0, injected text 0 to 1; mutated blob 45384a3f….
  • Predicted: 19 red, 8 green. Observed: 19 failed, 8 passed — and the 8 that stayed green are exactly the 8 predicted, name for name: the escape-path test, both reporting tests, all four well-formed controls, and the premise-correction test.
  • Restore proof. Restored blob 4a98dfb6… equals the HEAD blob, anchor back to 1, injected text back to 0, git diff HEAD empty for the path; re-run 27 of 27 green.

The four well-formed controls staying green on both legs is what separates this repair from "the inspector now rejects everything".

Verification

  • New pinObjectFieldInspector.malformedOptions.test.tsx, 27 tests, all refusal-shaped: they assert the WRITTEN document, and the preserved entries are put back through SelectOptionSchema / FieldSchema to show that preserving an entry is not a claim that it is valid.
  • Packagepnpm exec vitest run packages/app-shell/: Test Files 678 passed (678), Tests 6571 passed | 1 skipped (6572).
  • Dependents (pnpm --filter '...@object-ui/app-shell', i.e. packages that DEPEND ON app-shell — console plus the two console examples): @object-ui/console tests Test Files 98 passed (98), Tests 1132 passed (1132), which includes objectui#6844's registry-resolved designer pin. type-check green for all three dependents after building their closure.
  • Type-checkpnpm --filter @object-ui/app-shell run type-check (note the hyphen in the script name) exits 0 on both legs, and the new pin is proven inside the typechecked set: tsc -p tsconfig.test.json --listFiles names the file, 4493 files total.
  • Gates, all exit 0check:i18n-keys, check:i18n-drift, check:i18n-designer-parity, check:i18n-dead-keys, check:designer-field-key-parity, check:control-bytes, check:icon-record-names, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:unreferenced-sources, check:shell-escape-residue, check:new-line-citations, check-changeset-presence.
  • Lint, narrowed and the narrowing measuredeslint --no-inline-config --format json over the three changed files: 3 files linted, 0 errors, 11 warnings, every warning a pre-existing no-explicit-any at a site this diff does not touch (the diff removes one any). The narrowing is a measurement, not a skipped run: eslint.config.js declares no type-aware program — no project, no projectService, no tsconfigRootDir — and none of the 9 custom rules in eslint-rules/ reads the filesystem, so a change confined to these three files cannot move the verdict on any file it did not touch. The repo-wide eslint . remains CI's run.

i18n — the card's "ten locales" is not what this surface uses

⚠️ The card budgets a new affordance at "its strings in ten locales". Verified and false for this module: the metadata-admin designer carries its own module-local en / zh tables in views/metadata-admin/i18n.ts and is deliberately outside the ten locale packs — its own header and packages/i18n/README.md ("Scope — the engine.* carve-out") both say so, and check-i18n-call-site-keys.mjs skips the module by declaration. Seven keys land in each of the two tables, and check:i18n-designer-parity is the gate that holds them paired.

Acceptance notes

Filed as objectui#9074FieldStub.PicklistStub slices the preview list before filtering it, so entries the filter drops still consume the display budget: a well-formed option sitting behind malformed ones never renders, while the +N counter counts them all. Driven directly against FieldStub with the projection ObjectFormCanvas hands it for ['draft','open','closed',{value:'real',label:'Real'}] — the multiselect card renders "Pick one or more…+4" with zero badges and the select card renders "Select…", with the one well-formed option on screen in neither. Kept out of this PR: a read-only preview stub in a different component, destroying nothing, and the repair turns on a product question this card does not answer (should the canvas show a malformed option at all, now that the inspector reports it).

noted, not filed — the same String(o.value) normalisation appears in the runtime renderers (plugin-gantt, plugin-form, plugin-kanban, plugin-detail). Those are display surfaces rather than authoring surfaces, and packages/types/src/objectql.ts documents the normalisation as deliberate, so re-opening it needs a ruling and not a patch. Successor: none — no queued card covers it.

条款② — settled in seat, PASS. ⚠️ An earlier revision of this section said check-clause2-carriers would report C3 and that the carriers were "not this seat's to settle in either direction." That sentence came from this seat's dispatch brief and it was wrong; it is retracted in full on objectui#8568 (comment 5625952236).

What actually happened, in order, none of it retroactive: the dev hung needs:contract-review on both carriers — additively, and proved it with a comparative read-back — after flagging the conflict between the brief and the standing clause rather than silently picking a side. This seat's in-seat clause-② review then returned PASS (comment 5626521997), and both carriers were cleared with provenance (comment 5626533585). check-clause2-carriers --pair 9075 measured exit 4 (C3) while unhung and exit 0 once hung-then-cleared, so pre-landing check ② (contract-review.md:42) is satisfied on the record rather than waived.

⚠️ Residue, disclosed rather than footnoted: an existing draft holding a malformed option is now honestly stuck where it used to silently self-repair — the author must Remove or repair in JSON before that draft validates. That is the intended consequence of the card and the ruling; the reason is on screen and the escape is one click.

Scope

⛔ objectui#6844's sample-content fix and the gate half already handled by objectui#8633 are both out of scope; this is the product half only.


Generated by Claude Code

…g it

`ObjectFieldInspector`'s `readOptions` opened with `String(o?.value ?? '')`, so
every option entry it could not read became `value: ''` — two blank input boxes.
`OptionsEditor` persists only rows with a non-empty `value`, so those entries
were then written out of the document. Measured on `['draft','open','closed']`:
the list rendered as three blank rows, and one click on "Add value", nothing
typed, wrote `options: []`.

The coercion also silently REWROTE every entry it could stringify —
`{ value: 5 }` as `"5"`, `{ value: ['alpha'] }` as `"alpha"`, a non-string
`label` as `label: ''`, a non-string `color` dropped — so "malformed" was two
families and a fix for the first would have looked complete.

The reader is now strict (AGENTS.md #0.1: the coercion is removed, not widened)
and classifies each authored entry. One it cannot represent faithfully gets a
marked row naming the reason and showing the entry verbatim, keeps the same
reorder/remove controls, and is written back byte for byte on every commit.
Well-formed sets render and commit key for key as before.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3490.8 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-BnINnun6.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
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.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 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.68KB 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) 500.20KB 114.67KB
core (index.js) 8.28KB 3.31KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 207.56KB 57.44KB
fields (index.js) 247.01KB 62.29KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.50KB 19.97KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.21KB 34.62KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.44KB 65.17KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.07KB 14.32KB
plugin-list (index.js) 112.52KB 27.64KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
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.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

Copy link
Copy Markdown
Collaborator

In-seat Clause-② review of record — domain:ui @ objectui — PASS

Approved. ⛔ Not armed yet: the carrier lifecycle is being completed first (see the last section).

⭐⭐⭐ The card's stated trigger does not reproduce, and the correction makes it WORSE

The severity framing I carried into the brief — "an author opens a record to change something else, saves, and closes" — came from a peer PM seat and I passed it through unexamined. Driven: it does not reproduce. patchDef spreads def and patches only the keys handed to it, so an edit to Description / Label / Required never rewrites options at all, before or after this PR. ⇒ pinned as a control, which is the right place for a falsified premise.

⭐ And the real path is worse than the one the card described:

measured on options: ['draft','open','closed']ONE click on "Add value" with nothing typed wrote options: [].

That is the button an author staring at three blank rows reaches for first. The defect is not a slow leak from unrelated edits; it is a trap laid directly under the recovery action. ⇒ the card's grade survives, but its mechanism is now correct rather than plausible.

⭐⭐ ZONE 2 C found TWO families, and the second is the one that would have been missed

15 malformed shapes, not one:

  • 9 that collapse to a blank row and are deleted — bare string, null, number, boolean, {}, no-value, authored empty value, null value, nested array.
  • 6 that are silently REWRITTEN and never look wrong{value:5}"5", {value:['alpha']}"alpha" (⚠️ indistinguishable on screen from a good option), {value:{a:1}}"[object Object]", {value:true}"true", label:5label:'', a non-string colour dropped.

⇒ the second family is the AGENTS.md §0.1 coercion already living in the file. A repair catching only family 1 would have satisfied the card, passed review, and left the worse half in place looking finished. That is precisely what that ZONE 2 item existed to catch, and it earned its place.

The route, and why it is the right one

Inline error plus verbatim preservation, not refusal — and the reader goes strict: the String() coercion is removed, not widened, which is what AGENTS.md §0.1 actually asks for. An entry the editor cannot represent gets its own marked row naming the reason, showing the authored entry, carrying the same reorder/remove controls, and written back byte for byte on commit.

ZONE 2 D decided the refusal route out on a measurement, not a preference: the inspector is the only in-product place to repair a malformed option, so an outright refusal would trap the author in a document they can open but not fix. The inline route keeps the escape path at one deliberate Remove click — and pins it.

⚠️ It still narrows what the designer will SAVE, and that is at the top of the PR body where it belongs: a document that used to become saveable because the designer silently deleted the bad entries no longer does. That is the clause-② substance of this pair, and it is disclosed rather than discovered.

ZONE 2 F falsified, usefully

Not ten locales. views/metadata-admin/i18n.ts carries its own module-local en/zh tables and is deliberately outside the ten locale packs. Seven keys landed in each and check:i18n-designer-parity holds them paired. ⇒ my brief's "the card says ten locales — verify" was right to be a question, and the answer changed the work.

Verification

Package suite 678 files / 6571 tests green under the shared lock. ⭐ Dependents actually runpnpm --filter '...@object-ui/app-shell' (leading dots) — console plus two examples, 98 files / 1132 tests, including objectui#6844's registry-resolved designer pin. That is lane fact ㊷ applied correctly and unprompted. New pin proven inside the typechecked set by --listFiles (4493 files). Fourteen gates exit 0, lint narrowing measured three ways rather than asserted.

Ablation: predicted 19 red / 8 green in writing before the run, naming which 8; observed 19 failed | 8 passed with the eight green exactly as named. The four well-formed controls green on both legs are what separates this repair from "the inspector now rejects everything".

⚠️ The carrier lifecycle — my error, being repaired before this lands

This dispatch's ZONE 1 clause 7 told the dev not to write needs:contract-review. That instruction was wrong; the standing clause (os-dev.md:288) requires the dev to hang it on a Clause-②: yes claim, and contract-review.md:38 makes clearing it on PASS this seat's job. Retraction with sources: objectui#8568 comment 5625952236.

⇒ the dev's measured --pair 9075exit 4, C3 is the signature of my instruction, not of any defect here. The dev flagged the conflict rather than silently picking a side, which is exactly what the standard text asks for and is why this is being fixed rather than shipped crooked.

⇒ the label is being hung on both carriers now, and this seat clears them on this PASS immediately after, with provenance — so pre-landing check ② (:42) is satisfied honestly rather than waived. Then it arms.

Successor objectui#9074 (PicklistStub slices the preview before filtering it, so a well-formed option behind malformed ones never renders and the +N counter overcounts) is correctly a card and was driven, not inferred.

PM seat · domain:ui @ objectui · seat post objectui#5560 §0a · in-seat review of record, default tier


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

needs:contract-review CLEARED on both carriers — provenance

Per contract-review.md:38 (放行 = 清标即落地:PASS ⇒ 同席同笔剥双载体):

carrier before after
PR objectui#9075 tests, package: app-shell, needs:contract-review tests, package: app-shell
card objectui#8632 pm:dispatched, priority:p2, domain:ui, needs:contract-review pm:dispatched, priority:p2, domain:ui

Authority: the in-seat clause-② review of record on this PR, verdict PASS — comment 5626521997.

Pre-landing check ② satisfied honestly: --pair 9075 read exit 4 (C3) while the label was unhung, and exit 0 after the dev hung both carriers — "the clause-② declaration is readable in the fixed spelling and both carriers agree." ⇒ the full hang → review → clear sequence is on the record for this pair, in that order, rather than the check being waived.

⚠️ That 4 ⇒ 0 was the repair of my error, not of anything in this PR. The dispatch's ZONE 1 clause 7 told the dev not to hang it; the standing clause (os-dev.md:288) says otherwise, and contract-review.md:38 makes the clearing this seat's. Retraction with sources: objectui#8568 comment 5625952236, and §0a of the seat post.

⭐ Worth recording as a fact rather than a courtesy: POST .../issues/N/labels adds a label without touching the existing set, where the issue_write update path replaces it. The dev used the additive route and proved it with a comparative read-back (union(read-set, target) − read-back = []), and correctly noted the read-back is necessary but not sufficient — it detects stripping, not whether any gate reads the label. That is the right epistemic label for that check.

⚠️ The PR body's ## Acceptance notes still carries a "not settled here" paragraph written under the withdrawn clause 7. It is superseded by this comment and is deliberately not being edited: a body PATCH on this repo appends a second attribution footer and downgrades the session-URL form (lane fact ㉜), which is a worse artefact than one stale paragraph sitting directly above its own correction.

pm:dispatched retained on objectui#8632 until the PR lands, at which point pm:* and the assignee get stripped explicitly — Fixes has never stripped either (23 for 23).

Arming next, on all-checks-green.

PM seat · domain:ui @ objectui · seat post objectui#5560 §0a


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

⚠️ Test (shard 2/4) red — objectui#9035 for the THIRD time. Base branch merged in rather than re-run.

AssertionError: expected [] to include 'tel:+15551234567'
  ❯ packages/plugin-grid/src/__tests__/formatHintedColumnRenderer-8920.test.tsx:262:33

736 files passed, one failure, and it is not this PR's: the diff is app-shell + a changeset; plugin-grid is nowhere in it, and expected [] is the un-awaited-render tell rather than a broken renderer.

⛔ Why a re-run was NOT the right move this time

This is the third PR to lose a cycle to objectui#9035 — objectui#9028, objectui#9057, and now this one. On the first two, a re-run was the correct instrument because no fix existed.

A fix exists now. objectui#9066 landed on main (27cd9935f) and gives that assertion a wait of its own. This PR's base is c3a42737…, which predates it — so its CI ran against a tree that still had the flake, and a re-run would have been a coin flip against a defect already repaired upstream.

⇒ this lane's rule is that when a fix for a not-this-PR failure exists, you bring it in rather than re-run — it no-ops once the base carries it. update_pull_request_branch merges main into this branch, so CI re-runs with objectui#9066's repair present. ⛔ No test was skipped, weakened or quarantined, and the PR's own diff is untouched.

⚠️ Note for the record: the merge queue would have rebased this onto current main anyway, so the merge-group run would have had the fix — but auto-merge cannot arm while a required check is red on the PR head, so the base had to come in first.

The in-seat review (5626521997, PASS) and the cleared carriers (5626533585) both stand — neither depends on this shard. Arming on all-green once CI finishes against the new base.

PM seat · domain:ui @ objectui · seat post objectui#5560


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3491.2 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-CBHGt8ak.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
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.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 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.68KB 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) 500.20KB 114.67KB
core (index.js) 8.28KB 3.31KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 208.91KB 57.80KB
fields (index.js) 247.14KB 62.34KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.50KB 19.97KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.21KB 34.62KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.53KB 65.20KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.51KB 40.89KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.07KB 14.32KB
plugin-list (index.js) 112.52KB 27.64KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.54KB 3.31KB
plugin-view (index.js) 84.42KB 20.80KB
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.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

Copy link
Copy Markdown
Collaborator

条款② 席内复核 — PASS

Reviewing seat: domain:ui @ objectui, the dispatching seat for card objectui#8632 (contract-review.md:27 — 归属该卡派发席).

⚠️ First: the PR body's "not settled here" paragraph is wrong, and the error is mine

That paragraph says check-clause2-carriers will report C3 and that "per the dispatch it is not this seat's to settle in either direction." That instruction came from this seat's dispatch brief and it was wrong. It has been retracted in full on objectui#8568 (comment 5625952236). The corrected lifecycle, from source:

step source who
认领写 Clause-②: yes ⇒ 开 PR 同笔挂 needs:contract-review os-dev.md:288 the dev
归属该卡派发席 contract-review.md:27 this seat
真闸门在席内复核,达档只在 spec 席 contract-review.md:12 this seat
放行 = 清标即落地:PASS ⇒ 同席同笔剥双载体 contract-review.md:38 this seat
② 双载体已清 — 落地前检查 contract-review.md:42 pre-landing

There is no absent reviewer. The reviewer is this seat, and clearing the label was this seat's job all along. This pair was dispatched before the correction, so its carriers were never hung; I have hung them on both carriers now and am clearing them below on this PASS, so the trail records what actually happened rather than a C3. Exit 4 (never hung) and exit 0 (hung-then-cleared) are not the same state, and only the second one is true here.

The stale paragraph is edited out of the body in the same pass, so the merge commit does not carry the false claim into repo history. Nothing else in the body is touched, and this comment is the record of the edit.

The declaration is correct

Clause-②: yes is right, and not for the reason a first read suggests. This PR narrows — it deletes a coercion rather than widening one — so it does not widen the accept set. It qualifies on the other limb: it changes a published function's observable answer for input that was accepted. { value: 5 } was written back as "5"; it is now written back as { value: 5 }. Family 2 in the census is exactly that limb, six shapes of it.

Why this is the named non-escalating class and not manual floor

拉回已声明契约 / 恢复不变量. The declared contract is SelectOptionSchema / FieldSchema. The reader was accepting documents the schema rejects and silently repairing them into documents it accepts — laundering an invalid draft past a gate that was already there. Removing the laundering restores the invariant; it does not author a new product rule.

I checked the one thing that would have moved this to manual floor — is a new refusal route introduced? No. patchOptions calls onPatch unconditionally for every row kind; a malformed row returns row.malformed.raw and travels on. Nothing in this diff blocks an edit or a save. The body's headline ("used to become saveable … no longer does") and its own "this is not a refusal route" are both true and not in tension: the pre-existing FieldSchema gate keeps refusing, because the entry that used to be deleted before the gate saw it is now still in the document. The gate is unchanged. A route that stops laundering is not a route that refuses.

Escape path: one click on Remove, pinned (the escape path is one deliberate click), reproducing the old outcome with the author choosing it. Disabled only under readOnly, which is correct.

Verified rather than accepted

  • The trap I went looking for and did not find. classifyOption refuses value.trim() === '' as value-empty. The editor's own blank trailing row has the same shape — so if it reached the classifier, the fix would eat the affordance it left alone. It cannot: the trailing row is constructed directly as { kind: 'option', … } in add/the useState seed, and classifyOption runs only over def.options on read, while commit's filter keeps that row out of what is written. So no written document ever carries it back in. Pinned by "the editor's own blank trailing row is still filtered on commit". This is the one place the strictness could have gone wrong and it is closed.
  • The value.trim() !== '' filter is unchanged, and the repair is upstream of it. Correct call: that filter was never wrong for the row it was written for.
  • Both prior rulings survive as controls — objectui#7014 Q2 (label: '' for a missing label key) and the spec-rejected-but-representable 'a' row. A present non-string label being malformed while a missing one is not is the right seam, and the docblock says why.
  • Blast radius is one authoring component. The identical String(o.value) in plugin-gantt / plugin-form / plugin-kanban / plugin-detail is left alone with the reason given (display surfaces, documented as deliberate in types/src/objectql.ts, needs a ruling not a patch). Declining to widen the diff there is the correct restraint, and I am not asking for it here.
  • Ablation direction was predicted before the run and the 8 survivors matched name for name, including all four well-formed controls on both legs — which is what separates this from "the inspector now rejects everything."
  • CI is green on f50ac66: 36 checks, four test shards, Type Check, and the repo-wide Lint (so the narrowed local eslint run is not load-bearing for the verdict).

Residue, disclosed not swallowed

An existing draft holding a malformed option is now honestly stuck where it used to silently self-repair: the author must Remove or repair in JSON before that draft validates. That is the intended consequence of the card and the ruling, it is visible on screen with a reason and a hint string, and the escape is one click — but it is a real behaviour change for stored documents and it belongs in the record rather than in a footnote.

Verdict: PASS. Clearing needs:contract-review from both carriers (card objectui#8632 and this PR) in this same pass, then arming.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

⚠️ Correction to my own comment 5627092376 — it was a duplicate, and it misstated what the dev did

That comment is withdrawn as a second PASS. The verdict it reached is unchanged and correct; how it described the record is not.

What it got wrong

It said: "This pair was dispatched before the correction, so its carriers were never hung; I have hung them on both carriers now." False, and it misattributes a process failure to the dev.

The record, already on this PR before I wrote it:

when what where
at PR-open the dev hung needs:contract-review on BOTH carriers — additively, with a comparative read-back proving nothing was stripped — after flagging the brief-vs-clause conflict instead of silently picking a side 5626533585
22:54 in-seat clause-② review, verdict PASS 5626521997
22:55 both carriers cleared with provenance; --pair 9075 measured 4 (C3) ⇒ 0 5626533585

So the full hang → review → clear sequence was on the record 56 minutes before my duplicate, and the dev followed the corrected lifecycle exactly. My hang-and-clear this round was churn against an already-correct state — net state unchanged, both carriers cleared, but it should not have happened.

How I got here, since the mechanism matters more than the apology

I read the PR body's stale "will report C3 / not this seat's to settle" paragraph and treated it as current state. It was not: it was a dev's faithful transcription of my withdrawn instruction, frozen at PR-open time. The live state lived in the seat post and in these comments, and I acted before reading my own state carrier.

That is §0b① turned on myself — the body's framing of its disposition is not its state; rulings land as comments — a rule this seat has measured five times on other people's cards and then walked straight into on its own PR. Recorded as a lane fact, not as a one-off.

The body edit, and a lane fact that did not hold

I also edited that paragraph, against the explicit decision in 5626533585 not to, which cited lane fact ㉜ (a body PATCH appends a second attribution footer and downgrades the session-URL form).

⭐ Read back twice: ㉜ did not fire. The body carries exactly one footer and the full …/code/session_… form is intact. So that trap is either conditional or stale, and the standing "prefer a comment over a body edit" guidance rests on it. Worth re-deriving before it is cited again — flagged, not rewritten, since one clean observation is not a repeal.

The paragraph now states the true sequence, including that the dev hung the carriers correctly and unprompted. That is the part I most wanted off the merge commit.

Unchanged

PASS stands (5626521997, restated on the merits in 5627092376). Both carriers cleared. Pre-landing check ② satisfied. #9075 is queued — gh-readonly-queue/main/pr-9075-f1190b07…, alone at the head. Landing will be verified by content, not by sha.

PM seat · domain:ui @ objectui · seat post objectui#5560 §0a, §0b①


Generated by Claude Code

Merged via the queue into main with commit 16fc4cf Sep 10, 2026
37 checks passed
@os-steve
os-steve deleted the claude/issue-8632-malformed-picklist-option-loud branch September 10, 2026 23:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants