Skip to content

fix(app-shell): saving a view's config no longer turns the view read-only (objectui#10210) - #10332

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-10210-view-config-save-envelope
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-10210-view-config-save-envelope

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #10210
Clause-②: yes

What this changes

  • The save. handleViewConfigSave (packages/app-shell/src/views/ObjectView.tsx) now persists buildViewConfigSaveBody(objectName, draft): a ViewItem envelope { name, object, viewKind: 'list', label, config } built through the existing viewEnvelope(), addressed to the same row key as before (the tab id). config is the draft narrowed to the keys the spec's closed ListViewSchema declares; the row-level keys the switcher's own handlers write (isDefault, isPinned, sortOrder) plus visibility and columnState ride at the envelope's top level, so the whole-document PUT does not erase them.
  • The resume. ViewConfigPanel reads a pending draft back through the new storedViewToRuntimeView (view-config-adapter.ts): an envelope is read back to the runtime view, keyed by its row name; a flat draft stored before this change is returned as it is.
  • No published type or schema shape changes. @object-ui/data-objectstack is untouched: isLegacyOverlayRow is exactly as on main (see the fork below).

Why Part of

Triage step 1 (stop the bleed) is done here. Triage step 2, rows an earlier published save already made read-only, is measured below and reported as a fork: no repair rule ships in this PR, and the options with a recommendation are in the dev report on the card for the seat's decision card.

Measured end to end, before and after

Setup: the published @objectstack/cli 17.4.0 (objectstack dev --fresh, empty sqlite) serving a probe app with one object and three code-defined list views (defineView with list plus listViews.all and listViews.big); this worktree's console src through vite; Playwright on the preinstalled Chromium. Cells are tab-menu entry counts for probe_item.all.

step main 8b1f066 this branch
1. pristine code-defined view 6 6
2. Edit view config, change label, Save PUT /api/v1/meta/view/probe_item.all?mode=draft, flat body same URL, envelope body
3. normal mode 6 6
4. ?preview=draft 1 (Manage all views… only) 6
5–6. publish from the banner, back to normal mode 1, permanent 6
A2. reopen the panel on the pending draft, edit, Save again unreachable (entry hidden) second PUT on the same row, envelope shape
A3. toolbar density toggle after the published save 1 6
control: the never-saved sibling probe_item.big 6 6

The same run on the default view (probe_item.default) keeps isDefault: true across save, resume, save, publish and a toolbar toggle.

Captured save bodies:

  • main: {"label":"Everything EDITED","type":"grid","columns":[{"field":"subject"}],"name":"probe_item.all","isDefault":false,"id":"probe_item.all"}, served back flat with viewKind: "list" inherited from the code definition.
  • this branch: {"name":"probe_item.all","object":"probe_item","viewKind":"list","label":"Everything EDITED","config":{"type":"grid","columns":[{"field":"subject"}],"data":{"provider":"object","object":"probe_item"}},"isDefault":false}

The dispatch's four mechanism assumptions

  • A1 holds. The tab id and row key is the qualified probe_item.all. The envelope's name is pinned to the tab id verbatim: for an OBJECT.KEY id, viewEnvelope's own re-qualification yields the same string, and the pin keeps the save on its row for any id.
  • A2 falsified. The panel's resume read every stored draft as a flat view. On an envelope the resumed draft had no identity, and the next Save persisted nothing: zero PUTs, browser log [ViewConfigPanel] Cannot persist view config: missing metadataClient or viewId. Drafts of views created through handleViewCreate are envelopes too, so they already hit this. Repaired here; see "Outside the claimed file surface".
  • A3 holds. The toolbar write after the envelope save sends the whole tab: flat keys plus the nested config copy the tab carries. That is the same hybrid a toolbar toggle on a pristine code-defined view writes on main today; isLegacyOverlayRow returns false on it and the tab stays at 6.
  • A4 falsified, as anticipated. viewEnvelope(object, draft) as it stands is refused at the platform's view write gate (ViewMetadataSchema, the schema the spec's metadata-type table assigns to view), measured on the live 17.4.0 door: 422 INVALID_METADATA, config [unrecognized_keys], "Unrecognized key(s) on this list view: isDefault, id". Handled by narrowing config to ListViewSchema's declared keys; isDefault sits at the envelope's top level, where the gate accepts it and where listViews() reads it.

Triage step 2: rows already made read-only — measured, reported as a fork

Each writer's exact request replayed against the same 17.4.0 backend, then read back from GET /api/v1/meta/view:

  • config-saved and published (this defect): {label, type, columns, name, isDefault, id, viewKind, object}
  • a pre-marker toolbar overlay on a view that declares data: the same keys plus data, filter, rowHeight
  • a pre-marker toolbar overlay on a view declared without data (the view composer stamps none): {label, type, columns, name, isDefault, id, rowHeight, object, viewKind}

No structural key separates the first from the third: both flat, viewKind: "list", no marker, no data, id and isDefault on both. What differs is content (rowHeight here), which neither population requires or excludes. What does separate them is server-side provenance: GET /api/v1/meta/view/NAME/history shows a publish event for the config-saved row and a single direct create (protocol.saveMetaItem) for the replayed overlay; that is readable per row, not from the list read. So every client-side shape rule trades "an old overlay read as a saved view" against "a broken view stays read-only".

Meanwhile, measured: DELETE /api/v1/meta/view/NAME resets such a row to its code definition ("reset to artifact default") and drops the saved edits; the tab menu of a broken view offers no route to it. The secondary item (narrow or retire the shape heuristic) is one of the fork's options, so it is not in this PR.

Outside the claimed file surface

ViewConfigPanel.tsx and view-config-adapter.ts are not in the claim's file surface. The resume repair rides here because the envelope save would otherwise carry the A2 regression to every edited view, and all four in-place conditions hold: same defect class (the stored view draft's write and read shapes); a mechanical change whose shape is pinned by the envelope type and the create path; no open PR touches either file (27 open PRs' file lists read, the release PR's 1732 files enumerated in full with no source file among them); and the same gate family, app-shell tests. The claim's file surface needs the matching amendment.

Tests and gates (all at bbbba169b, clean tree)

  • pnpm exec vitest run --maxWorkers=2 over the 65 test files that import or read the changed modules, plus data-objectstack's listViews, viewOverlayMarker and listViewOverrides suites: Test Files 65 passed (65), Tests 944 passed (944).
  • New pins: ObjectView.viewConfigSaveEnvelope-10210.test.ts (the body passes ViewMetadataSchema, keeps the row key and row state, sheds the overlay marker; a re-read through the real adapter listViews() keeps the saved view and the never-saved control; resume then save is idempotent), ObjectView.viewConfigSaveWiring-10210.test.tsx (the real ObjectView save handler stages the envelope on the tab's row), ViewConfigPanel.resumeEnvelope-10210.test.tsx (an envelope resumes with its identity; a flat pre-change draft still resumes), and storedViewToRuntimeView cases in view-config-adapter.test.ts.
  • Type-check: turbo run build --filter=@object-ui/app-shell^... (28 of 28 tasks), then pnpm --filter @object-ui/app-shell type-check exit 0 (both tsc --noEmit and the test tsconfig, whose --listFilesOnly includes all four new or edited test files) and pnpm --filter @object-ui/data-objectstack type-check exit 0.
  • Reverse verification, from the committed state; each mutation went through ablation-replace (anchor hit once, blob changed, then restored to the HEAD blob with git diff HEAD empty):
    1. the handler back to persisting the flat draft: the wiring pin goes red (1 failed, 6 passed);
    2. the resume back to reading the stored body as flat: the envelope-resume pin goes red (1 failed, 6 passed);
    3. the builder keeping every draft key in config: 5 red (the spec refusal, both in the fake write door and in the direct ViewMetadataSchema check).
  • Gates, all exit 0: check-changeset-presence, check-changeset-no-major, check-changeset-fixed, check-changeset-overwrite, check:new-line-citations (0 new), check:changeset-claims, check:pending-changeset-literals, check:control-bytes, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:metadata-write-doors, check:spec-symbols, check:test-path-roots, check:phantom-deps. check-governed-queue-guard --test over the diff: NOT GOVERNED.
  • Lint, narrowed rather than the repo run: eslint under the root eslint.config.js (whose **/*.{ts,tsx} block covers every touched file) over the 7 touched TypeScript files; --format json reports 7 files and 0 errors. The one warning in new non-test code is react-refresh/only-export-components on the exported builder, which every sibling exported helper in the file also carries. The config enables no type-aware linting (no parserOptions.project, no projectService) and its custom rules are per-file, so this diff cannot move an untouched file's verdict. The repo-wide pnpm lint is CI's.

Acceptance notes

  • A tab whose id is not OBJECT.KEY (the synthesized all tab of an object with no views): the platform refuses both the old flat body (parsed as a view container) and an envelope under a bare name (ViewItemNameSchema). Measured at the protocol level only; the UI path was not driven. Unchanged by this PR.
  • The toolbar's saved-view branch (buildPersistedViewBody with isSavedView: true) still writes the whole tab, flat keys plus the nested config copy and the registry bookkeeping it carries. It stays clear of the legacy-overlay shape only because the tab carries that config. Unchanged here.
  • #10209 (the menu symptoms, seat 3) is not touched here; this PR shares no file with it.

Session https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C (dispatched by the domain:ui execution seat 4).


Generated by Claude Code

…d the panel resumes one

"Edit view config -> Save" wrote the flat runtime tab as the view's row. On a
code-defined view the server inherits `viewKind: 'list'` from the registry
entry, and a flat row carrying `viewKind` is the shape the adapter's
legacy-overlay net reads as a personalization overlay: `listViews()` dropped
the row, the tab was stamped read-only, and publishing made it permanent.

The save now writes `{ name, object, viewKind: 'list', label, config }`
through the existing `viewEnvelope()`, keyed by the tab id it always used.
`config` is narrowed to the keys the spec's closed `ListViewSchema` declares
(an envelope whose `config` carried the tab's `id` / `isDefault` is refused
with 422 by the platform's write door), and the row-level keys the switcher's
own handlers write (`isDefault`, `isPinned`, `sortOrder`, `visibility`,
`columnState`) are carried forward at the envelope's top level.

The panel's draft resume read every stored draft as a flat view; an envelope
draft lost its identity there and the next Save persisted nothing.
`storedViewToRuntimeView` reads both stored shapes back to the runtime view.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…e draft resume

- the builder's body is spec-valid (`ViewMetadataSchema`, the platform's `view`
  gate), keyed by the tab id, keeps row state and sheds the overlay marker;
- a re-read through the real adapter still lists the view as a saved view, and
  a never-saved sibling (the card's control) is unaffected;
- the real `ObjectView` save handler stages the envelope on the tab's row;
- the panel resumes a stored envelope with its identity, and a flat draft saved
  before the fix still resumes.

The handler's inline comment is trimmed back so the objectui#4155 ratchet's
"explicit save path" control still finds `persistRuntimeMetadata('view'`
within its window.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
`objectName` comes from `useParams()` and may be undefined; `viewEnvelope`
already accepts that. The draft is read as `Record<string, unknown>` and the
label is taken only when it is a string.

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

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 2 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/4730-objectview-config-keys-retired.md

  • names packages/app-shell/src/views/ViewConfigPanel.tsx → packages/app-shell/src/views/ViewConfigPanel.tsx — edited by this change

    The namespace held 209 keys per pack. 116 of them labelled a view-configuration settings panel that does not exist: appearance and density toggles, accessibility attributes, conditional-formatting rules, row-action and inline-edit switches, quick-filter builders, an advanced-settings tier. packages/app-shell/src/views/ViewConfigPanel.tsx — the panel they were written for — was migrated off the legacy buildViewConfigSchema engine onto ViewVariantInspector, a spec-driven inspector whose field labels come from @objectstack/spec metadata rather than from this namespace. The panel was replaced; the keys were not cleaned up with it.

.changeset/7070-no-invented-gantt-date-fields.md

  • names app-shell/src/views/ObjectView.tsx → packages/app-shell/src/views/ObjectView.tsx — edited by this change

    • app-shell/src/views/ObjectView.tsx — the console object page. The inline branch becomes ganttViewOptions, the sibling of calendarViewOptions and timelineViewOptions: the declared block spread whole, title floored at 'name', no date field invented. - plugin-list/src/ListView.tsx — the render branch AND the capability gate. - plugin-view/src/ObjectView.tsx — generateViewSchema, the authored object-view element route, which bypasses ListView entirely.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 1dbb9933c (merge-base with origin/main): 7 file(s) changed outside .changeset/, read against 1296 pending declaration(s) that publish a body (1865 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3039.4 KB 3104.5 KB
Main entry chunk (gzip) 147.9 KB 350 KB
Entry file index-I2AjH-ig.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.68KB 6.20KB
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) 541.66KB 129.51KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 222.47KB 61.83KB
fields (index.js) 253.41KB 64.01KB
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) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 33.36KB 10.88KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.15KB 11.05KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
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) 51.44KB 14.62KB
plugin-charts (index.js) 71.82KB 20.13KB
plugin-chatbot (index.js) 198.27KB 47.18KB
plugin-dashboard (index.js) 133.00KB 35.19KB
plugin-designer (index.js) 216.12KB 44.37KB
plugin-detail (index.js) 258.85KB 67.53KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 144.77KB 37.17KB
plugin-gantt (index.js) 167.99KB 41.37KB
plugin-grid (index.js) 215.46KB 58.88KB
plugin-kanban (index.js) 48.83KB 15.21KB
plugin-list (index.js) 113.90KB 28.11KB
plugin-map (index.js) 21.74KB 7.07KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.68KB 8.95KB
plugin-tree (index.js) 10.56KB 3.71KB
plugin-view (index.js) 85.18KB 21.05KB
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) 110.46KB 36.33KB
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 (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.78KB 2.56KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
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) 5.00KB 2.39KB
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) 17.15KB 6.32KB
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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bbbba169baa29bfbdbf9c192d35722270dd834ff

Rendered by an isolated review subagent spawned by the domain:ui#4 seat; its served tier was checked against its transcript stamps (all at the review tier). Adopted by this seat.

① Derived judgments

  • Inputs. PR fix(app-shell): saving a view's config no longer turns the view read-only (objectui#10210) #10332 (claude/issue-10210-view-config-save-envelope → main, open, draft-created by the fleet relay), head bbbba169baa29bfbdbf9c192d35722270dd834ff, 3 commits (a8b6f6ecc, 7b27d6265, bbbba169b). Merge-base with origin/main is 8b1f06619 (the API's base.sha 1dbb9933c is NOT an ancestor of the head; the merge-base diff is the 8-file diff GitHub lists: +686 −5). Card Saving a view's config turns that view into a read-only "system view" — irreversibly after publish (the config-panel save writes a FLAT body that isLegacyOverlayRow misjudges) #10210 body, triage 5817623588, claim 5818191684 and the dev report 5819580963 read in full. Local checkouts read via git --git-dir only (origin and pull/10332/head fetched); nothing modified, no gate family re-run.
  • (a) Envelope shape, against the PUBLISHED @objectstack/spec 17.4.0. Source used: the npm tarball spec-17.4.0.tgz (sha1 6d6d8dd0994a6a02ebac6665779341c5a9517719, downloaded to the scratch dir) — its dist/ui/index.mjs is byte-identical (sha256 fffdc6f9…c7e2a0) to the pnpm store copy the sibling worktrees hold, which is what a read-only node probe (scratchpad/pr-10332/probe.mjs, run from the scratch dir) imported. /home/user/objectui has no node_modules; the head lockfile resolves every @objectstack/spec specifier to 17.4.0; objectstack origin/main packages/spec/src/ui/view.zod.ts (also version 17.4.0) was read for the declarations. Findings:
    • The platform's view write gate is ViewMetadataSchema (kernel/metadata-type-schemas.ts: view: ViewMetadataSchema), a preprocess (identity assertion + console-decoration strip) over a union whose member 1 is ViewItemWireSchema = z.object({ viewKind, config, ...viewItemBaseShape(), ...viewItemWireFields() }).strip().
    • DECLARED at the envelope's top level: isDefault (viewItemBaseShape, with name, object, label, order, scope, owner, hidden, protection); isPinned, sortOrder, columnState (viewItemWireFields, wire member only — the strict authoring ViewItemSchema refuses them by name). visibility is NOT declared anywhere in view.zod.ts (the only visib* keys are visible on the tab schema and visibleWhen). Probe B/B2: an envelope carrying all five parses success=true; isDefault/isPinned/sortOrder/columnState survive in the parsed output, visibility is stripped from it. It is NOT refused, and it IS kept at rest because saveMetaItem (metadata-protocol/src/protocol.ts ~16077) validates with safeParse and then persists the ORIGINAL request.item verbatim ("parsed.data would strip Studio-only auxiliary fields… that intentionally ride along") — the same treatment the flat member gave visibility on main (probe D2), so no regression. Nuance for the record: the PR body's "where the gate accepts it" is literally true of isDefault/isPinned/sortOrder/columnState; visibility rides on the server's verbatim-persist policy, not on a declaration.
    • config narrowing is DERIVED, not hand-listed: getListViewConfigKeys() = Object.keys(SpecListViewSchema.shape) from @objectstack/spec/ui, computed lazily. Probe: .shape exists on the published lazy proxy and yields 50 keys — type, columns, filter, sort, data, grouping, rowHeight, hiddenFields, inlineEdit, kanban, gallery, calendar, gantt, timeline, map, tree, chart, pagination, selection, rowActions, bulkActions, userFilters, conditionalFormatting, fieldOrder, …. Everything the spec-driven ViewVariantInspector can edit is in that set and is kept (probe E: filter, sort, hiddenFields, inlineEdit, rowHeight all land in config). density is not a spec key (rowHeight is). The only hand list is VIEW_ROW_STATE_KEYS = ['isDefault','isPinned','sortOrder','visibility','columnState'] — it can drift from viewItemWireFields() if the spec adds a wire key; acceptable, noted.
    • Dropped relative to the old flat save (which the server kept verbatim): id, objectName, viewKind/object (re-stamped by viewEnvelope), the stale nested config copy, _isOverride, _draft, _diagnostics, _packageId, _provenance, and — worth naming — order, scope, owner, hidden, which the envelope base shape does declare. None is a key the panel edits (the live-captured main draft is {label,type,columns,name,isDefault,id}); objectui has no reader of .scope, no writer of hidden, and showSearch/showFilters/showSort (read at ObjectView.tsx:2972) are not in the 17.4.0 shape and have no writer. No legitimately edited key is dropped — no silent regression. Probe C confirms the A4 refusal: config carrying id/isDefault → invalid_union (the closed ListViewShapeSchema is strictObject).
  • (b) Row key and default flag. buildViewConfigSaveBody pins name: vid (the tab id) after viewEnvelope's own re-qualification, and handleViewConfigSave still calls persistRuntimeMetadata('view', vid, …) on the same vid — same URL segment and body name, so no second row. isDefault is carried at the top level; listViews() (data-objectstack/src/index.ts ~5406) flattens an envelope row as {...spec.config, name, label, isDefault: !!spec.isDefault}, so the default flag is the one row-state key that reader surfaces (pin/sortOrder/columnState stay at rest but are not read back by that path — pre-existing for every created view, unchanged here). Dev measured probe_item.default keeps isDefault: true through save/resume/publish/toggle.
  • (c) Resume repair (A2). Real on main: ViewConfigPanel.handleResumeDraft did runtimeViewToInspectorDraft(body as RuntimeView, …) on whatever readRuntimeDraft returned; for an envelope activeView.id is undefined so draft.name is undefined, and handleViewConfigSave then hits if (metadataClient && vid) false → console.warn('Cannot persist view config…'), zero PUTs (probe G reproduces: draft.id = undefined, config.config nested). It already bit drafts written by handleViewCreate (envelopes via createRuntimeMetadata). With this PR every config save leaves a pending ENVELOPE draft until publish, so without the repair reopening the panel on any edited view and saving again would be a silent no-op — taking it here is justified. The repair is minimal: one new pure function storedViewToRuntimeView (+36 lines, flat body returned by reference, envelope flattened with id = name) and a one-call change in the panel (+5 −3). Probe F: resume → save is idempotent (savedAgain equals stored).
  • (d) isLegacyOverlayRow. packages/data-objectstack/src/index.ts is byte-identical to origin/main (git diff --stat empty). Its first line if (spec && spec.config && typeof spec.config === 'object') return false; makes every new envelope save a non-overlay; the envelope pin re-reads through the real ObjectStackAdapter.listViews() and asserts the saved view and the never-saved control both stay saved.
  • (e) Toolbar path after an envelope save. persistViewPatch, buildPersistedViewBody and updateViewConfig are untouched. After the save the row lists as a saved view, so a toggle takes the isSavedView: true branch: whole tab + patch, no _isOverride — the identical write a pristine code-defined view takes on main today. The dev measured 6 entries after a density toggle post-publish (row a flat+nested "hybrid"). In the code I read, neither listViews() nor MetadataProvider.mergeViewsIntoObjects (viewItemBody returns view.config) re-nests config onto the tab, so the hybrid's nested copy is not something this PR produces or relies on; that fragility is pre-existing, named in the PR's acceptance notes, and unchanged.
  • Changeset .changeset/10210-view-config-save-envelope.md — every sentence checked: frontmatter '@object-ui/app-shell': patch; cause paragraph matches viewIdentityPatch (inherits exactly viewKind, object, label) and the legacy net; "same ViewItem envelope … that creating a view already stores, under the same view name" true; "config holds only the keys a list view declares (the platform refuses … id or isDefault)" true (probe C); "default flag, pin, order, visibility and column widths are kept beside it" true at rest (verbatim persist; visibility by tolerance, see (a)); resume paragraph true incl. "A change saved before this release is still stored flat and still resumes" (storedViewToRuntimeView returns a flat body as-is, pinned); the ⚠️ paragraph states rows already read-only are not repaired, gives the DELETE-through-the-metadata-API reset (matches metadata-protocol receipt "Customization overlay deleted — … reset to artifact default") and defers the in-product repair — it does not imply healing.
  • Pins. Four test files, each asserting the property its ablation leg would break: wiring pin asserts body.config and ViewMetadataSchema pass on the real ObjectView save (flat draft has no config → red); resume pin asserts saved.id === VIEW_ID on an envelope (flat reading → undefined → red, probe G); envelope pin runs the body through ViewMetadataSchema and a fake write door that throws on the spec refusal (keep every draft key → red, probe C). Real and able to fail; not re-run here (CI Test shards green).

② Semver level

patch on @object-ui/app-shell is correct. No published export changes: packages/app-shell/package.json exports only . and ./styles.css; src/index.ts re-exports named ObjectView and ViewConfigPanel from views/index.ts, which itself exports only those two from the touched modules — buildViewConfigSaveBody (ObjectView.tsx) and storedViewToRuntimeView (view-config-adapter.ts) are module-level exports for tests, not package API. No type or schema shape change; runtime-metadata-persistence.ts untouched (only its existing type ViewEnvelope is imported). New import ListViewSchema from @objectstack/spec/ui uses the existing dependency (^17.3.0, locked 17.4.0). Behaviour change is a bug fix on the persisted body shape. Not major.

③ Boundary flags

  • Part of #10210 (not Fixes) is correct: triage step 2 is reported as a fork (four options, recommendation recorded on the card), no repair rule ships, data-objectstack untouched. No closing keyword anywhere in title, body or the 3 commit messages (scan: none); #10209, objectui#4227, objectstack#7494 appear as references only.
  • No model identifier in any commit message, trailer, PR title or body. Trailers are Co-Authored-By: Claude (noreply at anthropic.com) — the product name — and the harness Claude-Session line; the PR body carries the session URL only.
  • File surface vs claim — two additions outside the claimed surface. Claim 5818191684 named ObjectView.tsx, runtime-metadata-persistence.ts (only if needed — untouched ✓), data-objectstack/src/index.ts (only if safe — untouched ✓), tests beside those, one changeset ✓, "stop on breach; explain in the report". The PR additionally modifies (1) packages/app-shell/src/views/ViewConfigPanel.tsx (+5 −3) and (2) packages/app-shell/src/views/view-config-adapter.ts (+36), with their tests ViewConfigPanel.resumeEnvelope-10210.test.tsx (new, +101) and view-config-adapter.test.ts (+26). The dev did not stop; the breach is disclosed in the PR ("Outside the claimed file surface", four in-place conditions) and in the report, and it is technically warranted by (c). The seat must amend the claim's file surface to ratify it.
  • Check-runs on bbbba169b: polled 18:15Z (28 success / 11 in_progress / 3 skipped), 18:23Z and 18:26Z — final: 43 total, 40 success, 3 skipped, 0 failure, 0 in_progress. Skipped: dependabot, Test (coverage), Test (coverage shard ${{ matrix.shard }}/4). Spec Main Shape Gate = success on this head even though the head predates 1dbb9933c (objectui fix(plugin-designer): NAV_TYPE_META has an entry for the spec's doc navigation item type (#10287) #10315), so no annotation check was needed. Lint, Type Check, Test (shard 1–8/8) all success.
  • Residuals (not blocking): visibility rides the envelope undeclared (kept at rest only by the server's verbatim persist — identical to main); VIEW_ROW_STATE_KEYS is a hand list beside a derived config set; the builder drops order/scope/owner/hidden that the envelope schema declares and the old flat member preserved (no objectui reader/writer today); viewEnvelope's String(label) would render a non-string i18n label as text (pre-existing, not introduced here).

Implemented-by: claude/issue-10210-view-config-save-envelope
Reviewed-by: session_01BP8CMtACxTdLjqR6rhd33C

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 18:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit baf98cd Sep 24, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-10210-view-config-save-envelope branch September 24, 2026 19:10
os-litant pushed a commit that referenced this pull request Sep 25, 2026
…arker only (objectui#10210, ruling B)

Retire `isLegacyOverlayRow`, the shape guess that also treated a flat
view row carrying `viewKind: 'list'` as a personalization overlay. An
"Edit view config -> Save" on a code-defined view stored that same shape
before PR #10332, so `listViews()` dropped the user's own view, the tab
was stamped read-only, and publishing made it permanent. Under the
maintainer's ruling B (objectui#10210, comment 5824008636) the marker is
the only discriminant: such rows heal on read with their edits, and the
one exposed class (overlay rows written before the marker and never
touched since) is named in the changeset and pinned.

The pins that asserted the guess are rewritten with the reason, not
deleted: `listViews`, `viewOverlayPatchOnly` (and the header of
`viewOverlayMarker`), plus the two app-shell pins that ran the real
adapter through the same guess. The two pending changesets this makes
false are corrected in place.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant